├── .gitignore
├── README.md
├── jsconfig.json
├── package-lock.json
├── package.json
├── previews
├── 1.gif
├── 2.gif
├── 3.gif
├── 4.gif
├── 5.gif
├── 6.gif
├── 7.gif
├── 8.gif
├── 9.gif
├── twitter_logo.png
└── twitter_prize.png
├── public
├── favicon.png
├── index.html
├── logo192.png
├── logo512.png
├── manifest.json
└── robots.txt
└── src
├── components
├── App.js
├── Loading.js
├── Notification.js
├── Router.js
├── Tweet.js
└── TweetForm.js
├── firebaseConfiguration.js
├── images
├── apple-logo.jpeg
├── apple-logo.png
├── coding-logo.png
├── github-logo.svg
├── google-logo.svg
├── nasa-logo.jpeg
├── nomadcoder-logo-black.jpeg
├── nomadcoder-logo.svg
├── tesla-logo.png
└── user.png
├── index.js
├── routes
├── Authentication.js
├── Home.js
└── Profile.js
└── theme
└── GlobalStyle.js
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
25 | .env
26 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
10 |
11 | ## Table of contents
12 |
13 | - 🔥 [Built with](#built-with)
14 | - 🌈 [Project](#project)
15 | - 📑 [Pages](#pages)
16 | - ⚙ [Features](#features)
17 | - 📝 [License](#license)
18 |
19 | ## Built with
20 |
21 | ### Front-end
22 |
23 | - `React`
24 | - `Javascript`
25 | - `Styled Components`
26 |
27 | ### Back-end
28 |
29 | - `Firebase`
30 |
31 | ### Deploy
32 |
33 | - `GitHub`
34 |
35 | ## Project
36 |
37 | > 1. 회원가입, 로그인
38 |
39 | - 유저는 이메일, 비밀번호를 이용해 회원가입을 할 수 있습니다.
40 | - 기존의 구글 또는 깃허브 계정을 이용해 소셜 로그인을 할 수 있습니다.
41 | - 가입한 계정 정보는 파이어베이스의 `Authentication`에 저장됩니다.
42 |
43 |
44 |
45 | > 2. 이메일 변경, 비밀번호 변경
46 |
47 | - 유저는 현재 사용 중인 이메일을 새롭게 변경할 수 있습니다.
48 | - 새로운 이메일 주소로 변경할 때, 기존 이메일을 통해 인증받아서 변경할 수 있습니다.
49 | - 유저는 현재 사용 중인 비밀번호를 새롭게 변경할 수 있습니다.
50 |
51 |
52 |
53 | > 3. 유저 프로필, 전체 트윗 확인
54 |
55 | - 유저는 프로필 페이지에서 가입한 이메일 주소와 인증 여부, 계정 생성일, 마지막 로그인 날짜 등을 확인할 수 있습니다.
56 | - 유저는 자신이 작성한 전체 트윗 리스트를 확인할 수 있습니다.
57 |
58 |
59 |
60 | > 4. 프로필 수정 및 프로필 사진 업로드
61 |
62 | - 유저는 프로필 페이지에서 유저 이름을 변경하고, 프로필 사진을 업로드할 수 있습니다.
63 | - 업로드한 사진은 파이어베이스의 `Storage`에 저장됩니다.
64 |
65 |
66 |
67 | > 5. 트윗 생성, 트윗 수정, 트윗 삭제
68 |
69 | - 유저는 텍스트, 이미지 등을 넣어 트윗을 생성할 수 있습니다.
70 | - 생성된 트윗은 유저 정보와 트윗 정보를 포함해서 파이어베이스의 `Firestore Database`에 저장됩니다.
71 | - 유저는 자신이 작성한 트윗을 수정 및 삭제할 수 있습니다.
72 |
73 |
74 |
75 | > 6. 좋아요, 좋아요 취소
76 |
77 | - 유저는 모든 트윗에 좋아요 또는 좋아요 취소를 할 수 있습니다.
78 | - 좋아요를 누르게 되면 파이어베이스의 `Firestore Database`에 저장되며, 실시간으로 좋아요 갯수를 업데이트합니다.
79 |
80 |
81 |
82 | > 7. 전체 트윗 수, 다른 유저가 작성한 전체 트윗 확인
83 |
84 | - 홈에서 현재 작성된 전체 트윗 수를 확인할 수 있습니다.
85 | - 유저를 클릭해 다른 유저가 작성한 전체 트윗을 확인할 수 있습니다.
86 |
87 |
88 |
89 | > 8. 플래시 메세지
90 |
91 | - 유저가 회원가입, 로그인, 트윗, 프로필 업데이트 등의 동작을 실행할 때, 화면 오른쪽 상단에 작은 플래시 메세지를 보여줍니다.
92 | - 플래시 메세지는 alert와 다르게 클릭하지 않아도 일정 시간이 지나면 자동으로 사라집니다.
93 |
94 |
95 |
96 | > 9. 기타
97 |
98 | - 2021년 9월 노마드코더에서 진행했던 트위터 클론 컨테스트에서 대상에 선정되어 애플워치를 받았습니다.
99 |
100 |
101 |
102 | ## Pages
103 |
104 | > Root
105 |
106 | - 홈
107 | - 프로필
108 |
109 | ## Features
110 |
111 | ### 🙎♂️ User
112 |
113 | - [x] 회원가입
114 | - [x] 로그인 / 로그아웃
115 | - [x] 구글 / 깃허브 로그인
116 | - [x] 아바타 업로드
117 | - [x] 프로필 수정
118 | - [x] 이메일 변경
119 | - [x] 비밀번호 변경
120 |
121 | ### 🧑💻 Tweet
122 |
123 | - [x] 트윗 생성
124 | - [x] 트윗 수정
125 | - [x] 트윗 삭제
126 | - [x] 트윗 확인
127 | - [x] 좋아요, 좋아요 취소
128 |
129 | ## License
130 |
131 | MIT
132 |
--------------------------------------------------------------------------------
/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": "src"
4 | },
5 | "include": ["src"]
6 | }
7 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "twitter-clone",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@fortawesome/fontawesome-free": "^5.15.4",
7 | "@fortawesome/fontawesome-svg-core": "^1.2.36",
8 | "@fortawesome/free-brands-svg-icons": "^5.15.4",
9 | "@fortawesome/free-regular-svg-icons": "^5.15.4",
10 | "@fortawesome/free-solid-svg-icons": "^5.15.4",
11 | "@fortawesome/react-fontawesome": "^0.1.15",
12 | "@testing-library/jest-dom": "^5.11.4",
13 | "@testing-library/react": "^11.1.0",
14 | "@testing-library/user-event": "^12.1.10",
15 | "emoji-picker-react": "^3.4.8",
16 | "firebase": "^8.10.0",
17 | "gh-pages": "^3.2.3",
18 | "prop-types": "^15.7.2",
19 | "react": "^17.0.2",
20 | "react-dom": "^17.0.2",
21 | "react-helmet": "^6.1.0",
22 | "react-modal": "^3.14.3",
23 | "react-notifications": "^1.7.2",
24 | "react-router-dom": "^5.3.0",
25 | "react-scripts": "4.0.3",
26 | "styled-components": "^5.3.1",
27 | "styled-reset": "^4.3.4",
28 | "underscore": "^1.13.1",
29 | "uuid": "^8.3.2",
30 | "web-vitals": "^1.0.1"
31 | },
32 | "scripts": {
33 | "start": "react-scripts start",
34 | "build": "react-scripts build",
35 | "test": "react-scripts test",
36 | "eject": "react-scripts eject",
37 | "predeploy": "npm run build",
38 | "deploy": "gh-pages -d build"
39 | },
40 | "eslintConfig": {
41 | "extends": [
42 | "react-app",
43 | "react-app/jest"
44 | ]
45 | },
46 | "browserslist": {
47 | "production": [
48 | ">0.2%",
49 | "not dead",
50 | "not op_mini all"
51 | ],
52 | "development": [
53 | "last 1 chrome version",
54 | "last 1 firefox version",
55 | "last 1 safari version"
56 | ]
57 | },
58 | "homepage": "https://githubgw.github.io/twitter-clone"
59 | }
60 |
--------------------------------------------------------------------------------
/previews/1.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GitHubGW/twitter-clone/faa3079286b8349d4a37f1101a2dff27e2e6ef64/previews/1.gif
--------------------------------------------------------------------------------
/previews/2.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GitHubGW/twitter-clone/faa3079286b8349d4a37f1101a2dff27e2e6ef64/previews/2.gif
--------------------------------------------------------------------------------
/previews/3.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GitHubGW/twitter-clone/faa3079286b8349d4a37f1101a2dff27e2e6ef64/previews/3.gif
--------------------------------------------------------------------------------
/previews/4.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GitHubGW/twitter-clone/faa3079286b8349d4a37f1101a2dff27e2e6ef64/previews/4.gif
--------------------------------------------------------------------------------
/previews/5.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GitHubGW/twitter-clone/faa3079286b8349d4a37f1101a2dff27e2e6ef64/previews/5.gif
--------------------------------------------------------------------------------
/previews/6.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GitHubGW/twitter-clone/faa3079286b8349d4a37f1101a2dff27e2e6ef64/previews/6.gif
--------------------------------------------------------------------------------
/previews/7.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GitHubGW/twitter-clone/faa3079286b8349d4a37f1101a2dff27e2e6ef64/previews/7.gif
--------------------------------------------------------------------------------
/previews/8.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GitHubGW/twitter-clone/faa3079286b8349d4a37f1101a2dff27e2e6ef64/previews/8.gif
--------------------------------------------------------------------------------
/previews/9.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GitHubGW/twitter-clone/faa3079286b8349d4a37f1101a2dff27e2e6ef64/previews/9.gif
--------------------------------------------------------------------------------
/previews/twitter_logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GitHubGW/twitter-clone/faa3079286b8349d4a37f1101a2dff27e2e6ef64/previews/twitter_logo.png
--------------------------------------------------------------------------------
/previews/twitter_prize.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GitHubGW/twitter-clone/faa3079286b8349d4a37f1101a2dff27e2e6ef64/previews/twitter_prize.png
--------------------------------------------------------------------------------
/public/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GitHubGW/twitter-clone/faa3079286b8349d4a37f1101a2dff27e2e6ef64/public/favicon.png
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 | 트위터
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/public/logo192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GitHubGW/twitter-clone/faa3079286b8349d4a37f1101a2dff27e2e6ef64/public/logo192.png
--------------------------------------------------------------------------------
/public/logo512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GitHubGW/twitter-clone/faa3079286b8349d4a37f1101a2dff27e2e6ef64/public/logo512.png
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | },
10 | {
11 | "src": "logo192.png",
12 | "type": "image/png",
13 | "sizes": "192x192"
14 | },
15 | {
16 | "src": "logo512.png",
17 | "type": "image/png",
18 | "sizes": "512x512"
19 | }
20 | ],
21 | "start_url": ".",
22 | "display": "standalone",
23 | "theme_color": "#000000",
24 | "background_color": "#ffffff"
25 | }
26 |
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/src/components/App.js:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 | import { authService } from "firebaseConfiguration";
3 | import { NotificationContainer } from "react-notifications";
4 | import "react-notifications/lib/notifications.css";
5 | import Router from "components/Router";
6 | import GlobalStyle from "theme/GlobalStyle";
7 | import Loading from "./Loading";
8 | import { createNotification } from "./Notification";
9 |
10 | const App = () => {
11 | const [initializeFirebase, setInitializeFirebase] = useState(false); // 파이어베이스 초기화 확인
12 | const [isLoggedIn, setIsLoggedIn] = useState(false); // 로그인 여부 확인
13 | const [userObject, setUserObject] = useState(null); // 로그인한 사용자 정보
14 | const [isDark, setIsDark] = useState(true); // 다크모드 확인
15 |
16 | // 다크모드 전환
17 | const changeTheme = () => {
18 | setIsDark(!isDark);
19 | };
20 |
21 | // 프로필 닉네임 변경시 리액트를 리랜더링 시킴
22 | const refreshDisplayName = () => {
23 | // console.log("refreshDisplayName", userObject);
24 | const currentUserObject = authService.currentUser;
25 |
26 | setUserObject({
27 | uid: currentUserObject.uid,
28 | displayName: currentUserObject.displayName,
29 | email: currentUserObject.email,
30 | emailVerified: currentUserObject.emailVerified,
31 | photoURL: currentUserObject.photoURL,
32 | creationTime: currentUserObject.metadata.a,
33 | lastSignInTime: currentUserObject.metadata.b,
34 | updateProfile: (displayName) => currentUserObject.updateProfile(displayName),
35 | });
36 | };
37 |
38 | useEffect(() => {
39 | // authService에 AuthStateChanged 이벤트 추가
40 | authService.onAuthStateChanged((userObject) => {
41 | if (userObject) {
42 | if (userObject.displayName === null) {
43 | userObject.updateProfile({
44 | displayName: "유저",
45 | });
46 | }
47 | setIsLoggedIn(true);
48 | setUserObject({
49 | uid: userObject.uid,
50 | displayName: userObject.displayName,
51 | email: userObject.email,
52 | emailVerified: userObject.emailVerified,
53 | photoURL: userObject.photoURL,
54 | creationTime: userObject.metadata.a,
55 | lastSignInTime: userObject.metadata.b,
56 | updateProfile: (displayName) => userObject.updateProfile(displayName),
57 | });
58 | } else {
59 | setIsLoggedIn(false);
60 | setUserObject(null);
61 | }
62 | setInitializeFirebase(true);
63 | });
64 | }, []);
65 |
66 | return (
67 | <>
68 | {/* 파이어베이스 초기화 후 실행 */}
69 | {initializeFirebase ? (
70 | <>
71 |
72 |
80 |
81 | >
82 | ) : (
83 |
84 | )}
85 | >
86 | );
87 | };
88 |
89 | export default App;
90 |
--------------------------------------------------------------------------------
/src/components/Loading.js:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
3 | import { faTwitter } from "@fortawesome/free-brands-svg-icons";
4 |
5 | const LoadingContainer = styled.section`
6 | position: absolute;
7 | top: 50%;
8 | left: 50%;
9 | transform: translate(-50%, -50%);
10 | display: flex;
11 | flex-direction: column;
12 | align-items: center;
13 | `;
14 |
15 | const LoadingIcon = styled(FontAwesomeIcon)`
16 | font-size: 40px;
17 | color: #1da1f2;
18 | `;
19 |
20 | const LoadingTitle = styled.h1`
21 | font-size: 16px;
22 | color: #1da1f2;
23 | `;
24 |
25 | const Loading = () => {
26 | return (
27 |
28 |
29 | Loading...
30 |
31 | );
32 | };
33 |
34 | export default Loading;
35 |
--------------------------------------------------------------------------------
/src/components/Notification.js:
--------------------------------------------------------------------------------
1 | import { NotificationManager } from "react-notifications";
2 |
3 | // 플래시 메세지
4 | export const createNotification = (type) => {
5 | switch (type) {
6 | case "SuccessRegister":
7 | NotificationManager.success("계정 생성을 성공하였습니다.", "성공", 1200);
8 | break;
9 | case "SuccessLogin":
10 | NotificationManager.success("이메일 로그인에 성공하였습니다.", "성공", 1200);
11 | break;
12 | case "FailLogin":
13 | NotificationManager.error("로그인에 실패하였습니다.", "실패", 1200);
14 | break;
15 | case "SuccessGoogleLogin":
16 | NotificationManager.success("구글 로그인에 성공하였습니다.", "성공", 1200);
17 | break;
18 | case "FailGoogleLogin":
19 | NotificationManager.error("구글 로그인에 실패하였습니다.", "실패", 1200);
20 | break;
21 | case "SuccessGithubLogin":
22 | NotificationManager.success("깃허브 로그인에 성공하였습니다.", "성공", 1200);
23 | break;
24 | case "FailGithubLogin":
25 | NotificationManager.error("깃허브 로그인에 실패하였습니다.", "실패", 1200);
26 | break;
27 | case "SuccessLogout":
28 | NotificationManager.success("로그아웃 되었습니다.", "성공", 1200);
29 | break;
30 | case "NotLogin":
31 | NotificationManager.error("로그인 후 이용 가능합니다.", "실패", 1600);
32 | break;
33 | case "SuccessPostTweet":
34 | NotificationManager.success("트윗을 작성하였습니다.", "성공", 1500);
35 | break;
36 | case "SuccessEditTweet":
37 | NotificationManager.success("트윗을 수정하였습니다.", "성공", 1500);
38 | break;
39 | case "SuccessDeleteTweet":
40 | NotificationManager.success("트윗을 삭제하였습니다.", "성공", 1500);
41 | break;
42 | case "SuccessProfile":
43 | NotificationManager.success("프로필을 업데이트하였습니다.", "성공", 1500);
44 | break;
45 | case "SuccessChangePassword":
46 | NotificationManager.success("비밀번호를 변경하였습니다.", "성공", 1500);
47 | break;
48 | case "SuccessChangeEmail":
49 | NotificationManager.success("이메일을 변경하였습니다.", "성공", 1500);
50 | break;
51 | case "FailChangePassword":
52 | NotificationManager.error("비밀번호 변경을 실패하였습니다.", "실패", 1600);
53 | break;
54 | case "FailChangeEmail":
55 | NotificationManager.error("이메일 변경을 실패하였습니다.", "실패", 1600);
56 | break;
57 | case "info":
58 | NotificationManager.info("info message", "info", 1500);
59 | break;
60 | case "warning":
61 | NotificationManager.warning("Warning message", "Warning", 1500);
62 | break;
63 | case "error":
64 | NotificationManager.error("Error message", "Error", 1500, () => {
65 | alert("callback");
66 | });
67 | break;
68 | default:
69 | break;
70 | }
71 | };
72 |
--------------------------------------------------------------------------------
/src/components/Router.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { HashRouter, Switch, Route, Redirect } from "react-router-dom";
3 | import PropTypes from "prop-types";
4 | import Home from "routes/Home";
5 | import Authentication from "routes/Authentication";
6 |
7 | const Router = ({ isLoggedIn, userObject, refreshDisplayName, createNotification, isDark, changeTheme }) => {
8 | return (
9 |
10 | {isLoggedIn ? (
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | ) : (
21 |
22 |
23 |
24 |
25 |
26 |
27 | )}
28 |
29 | );
30 | };
31 |
32 | Router.propTypes = {
33 | isLoggedIn: PropTypes.bool.isRequired,
34 | userObject: PropTypes.object,
35 | refreshDisplayName: PropTypes.func.isRequired,
36 | createNotification: PropTypes.func.isRequired,
37 | isDark: PropTypes.bool.isRequired,
38 | changeTheme: PropTypes.func.isRequired,
39 | };
40 |
41 | export default Router;
42 |
--------------------------------------------------------------------------------
/src/components/Tweet.js:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { firestoreService, storageService } from "firebaseConfiguration";
3 | import styled from "styled-components";
4 | import PropTypes from "prop-types";
5 | import Modal from "react-modal";
6 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
7 | import { faHeart as faHeart2, faTimes } from "@fortawesome/free-solid-svg-icons";
8 | import { faEdit, faHeart, faTrashAlt } from "@fortawesome/free-regular-svg-icons";
9 | import userImage from "images/user.png";
10 |
11 | const PostingTweetContainer = styled.div`
12 | display: flex;
13 | padding: 10px 17px;
14 | background-color: ${(props) => props.currentLight && "#f8f8f8"};
15 | background-color: ${(props) => props.currentDark && "#1e2125"};
16 | border-bottom: 1px solid ${(props) => (props.current === "true" ? "#1e2125" : "#eee")};
17 |
18 | &:hover {
19 | background-color: ${(props) => (props.current === "true" ? "#1e2125" : "#f8f8f8")};
20 | }
21 | `;
22 |
23 | const PostingTweetAuthorImage = styled.img`
24 | width: 47px;
25 | height: 47px;
26 | border-radius: 50%;
27 | margin-right: 17px;
28 | cursor: pointer;
29 |
30 | @media (max-width: 768px) {
31 | margin-right: 10px;
32 | }
33 | `;
34 |
35 | const PostingTweetContent = styled.div`
36 | width: 100%;
37 | `;
38 |
39 | const EditingTweetForm = styled.form`
40 | width: 100%;
41 | `;
42 |
43 | const PostingTweetAuthor = styled.div`
44 | display: flex;
45 | align-items: center;
46 | justify-content: space-between;
47 |
48 | @media (max-width: 768px) {
49 | flex-direction: column;
50 | }
51 | `;
52 |
53 | const AuthorInfo = styled.div`
54 | display: flex;
55 | align-items: center;
56 | height: 40px;
57 | cursor: pointer;
58 |
59 | @media (max-width: 768px) {
60 | width: 100%;
61 | justify-content: flex-start;
62 | }
63 | `;
64 |
65 | const PostingEditDelete = styled.div`
66 | display: flex;
67 | align-items: center;
68 |
69 | @media (max-width: 768px) {
70 | width: 100%;
71 | justify-content: flex-end;
72 | }
73 | `;
74 |
75 | const AuthorName = styled.h2`
76 | font-size: 17px;
77 | font-weight: bold;
78 |
79 | @media (max-width: 768px) {
80 | font-size: 15px;
81 | }
82 | `;
83 |
84 | const AuthorEmail = styled.h3`
85 | font-size: 16px;
86 | margin-left: 7px;
87 | color: gray;
88 | font-weight: 500;
89 |
90 | @media (max-width: 768px) {
91 | font-size: 15px;
92 | }
93 | `;
94 |
95 | const AuthorCreatedAt = styled.h4`
96 | font-size: 14px;
97 | color: gray;
98 | font-weight: 500;
99 |
100 | @media (max-width: 768px) {
101 | font-size: 13px;
102 | }
103 | `;
104 |
105 | const AuthorDot = styled.span`
106 | font-size: 15px;
107 | margin: 0 5px;
108 |
109 | @media (max-width: 768px) {
110 | font-size: 12px;
111 | }
112 | `;
113 |
114 | const PostingTweetDesc = styled.p`
115 | margin-bottom: 8px;
116 | font-size: 16px;
117 | line-height: 1.5;
118 | `;
119 |
120 | const PostingEditTweetDesc = styled.textarea`
121 | margin-bottom: 10px;
122 | font-size: 16px;
123 | line-height: 1.5;
124 | border: none;
125 | outline: none;
126 | width: 100%;
127 | padding: 10px 12px;
128 | box-sizing: border-box;
129 | margin-top: 7px;
130 | color: #989898;
131 | border-radius: 5px;
132 | resize: none;
133 | background-color: ${(props) => props.currentLight && "white"};
134 | background-color: ${(props) => props.currentDark && "#404040"};
135 |
136 | &::-webkit-scrollbar {
137 | width: 11px;
138 | height: 11px;
139 | background: #ffffff;
140 | }
141 | &::-webkit-scrollbar-thumb {
142 | border-radius: 7px;
143 | background-color: #787878;
144 |
145 | &:hover {
146 | background-color: #c0c0c0;
147 | }
148 | &:active {
149 | background-color: #c0c0c0;
150 | }
151 | }
152 | &::-webkit-scrollbar-track {
153 | background-color: lightgray;
154 | }
155 | `;
156 |
157 | const PostingTweetImage = styled.img`
158 | width: 490px;
159 | height: 280px;
160 | border-radius: 15px;
161 | cursor: pointer;
162 |
163 | @media (max-width: 768px) {
164 | width: 100%;
165 | height: 200px;
166 | }
167 | `;
168 |
169 | const PostingTweetLike = styled.button`
170 | margin-top: 8px;
171 | display: flex;
172 | align-items: center;
173 | margin-left: -10px;
174 | `;
175 |
176 | const PostingTweetEdit = styled.button``;
177 |
178 | const PostingTweetDelete = styled.button``;
179 |
180 | const IconTweetLike = styled(FontAwesomeIcon)`
181 | cursor: pointer;
182 | font-size: 17px;
183 | color: #f91880;
184 | padding: 10px;
185 |
186 | &:hover {
187 | background-color: rgba(249, 24, 128, 0.2);
188 | border-radius: 50%;
189 | }
190 |
191 | @media (max-width: 768px) {
192 | font-size: 13px;
193 | }
194 | `;
195 |
196 | const IconTweetLikeNumber = styled.span`
197 | color: #f91880;
198 | font-size: 15px;
199 | font-weight: 500;
200 | margin-left: 5px;
201 | `;
202 |
203 | const IconTweetEdit = styled(FontAwesomeIcon)`
204 | cursor: pointer;
205 | font-size: 18px;
206 | color: gray;
207 | padding: 10px;
208 | border-radius: 50%;
209 |
210 | &:hover {
211 | color: var(--twitter-color);
212 | background-color: ${(props) => (props.current === "true" ? "#404040" : "#e6f3ff")};
213 | }
214 |
215 | @media (max-width: 768px) {
216 | padding: 0;
217 | font-size: 15px;
218 | margin-right: 10px;
219 | }
220 | `;
221 |
222 | const IconTweetDelete = styled(FontAwesomeIcon)`
223 | cursor: pointer;
224 | font-size: 18px;
225 | color: gray;
226 | padding: 10px;
227 | border-radius: 50%;
228 |
229 | &:hover {
230 | color: var(--twitter-color);
231 | background-color: ${(props) => (props.current === "true" ? "#404040" : "#e6f3ff")};
232 | }
233 |
234 | @media (max-width: 768px) {
235 | padding: 0;
236 | font-size: 15px;
237 | }
238 | `;
239 |
240 | const EditTweetBtn = styled.button`
241 | padding: 6px 10px;
242 | color: white;
243 | border-radius: 30px;
244 | font-size: 14px;
245 | font-weight: bold;
246 | background-color: #74b9ff;
247 |
248 | &:hover {
249 | background-color: rgb(29, 161, 242);
250 | }
251 | `;
252 |
253 | const DeleteTweetBtn = styled.button`
254 | margin-left: 4px;
255 | padding: 6px 10px;
256 | color: white;
257 | border-radius: 30px;
258 | font-size: 14px;
259 | font-weight: bold;
260 | background-color: #ff7979;
261 |
262 | &:hover {
263 | background-color: #eb4d4b;
264 | }
265 | `;
266 |
267 | const IconSVGContainer = styled.div`
268 | display: flex;
269 | align-items: center;
270 | justify-content: space-between;
271 | margin-top: 7px;
272 | `;
273 |
274 | const IconHeartContainer = styled.div`
275 | display: flex;
276 | align-items: center;
277 | width: 50px;
278 | `;
279 |
280 | const IconSVG = styled.svg`
281 | height: 20px;
282 | cursor: pointer;
283 | border-radius: 50%;
284 | padding: 5px;
285 |
286 | &:hover {
287 | fill: ${(props) => (props.current === "true" ? "#1DA1F2" : "#bebebe")};
288 | background-color: ${(props) => (props.current === "true" ? "#2E3336" : "#e6f3ff")};
289 | }
290 | `;
291 |
292 | const IconG = styled.g``;
293 |
294 | const IconPath = styled.path``;
295 |
296 | // 팔로워 폼
297 | const LoginFormContainer = styled.div`
298 | position: fixed;
299 | top: 50%;
300 | left: 50%;
301 | transform: translate(-50%, -50%);
302 | width: 625px;
303 | height: 700px;
304 | overflow-y: scroll;
305 | z-index: 10;
306 | background-color: white;
307 | border-radius: 20px;
308 | z-index: 100;
309 | box-shadow: rgba(0, 0, 0, 0.4) 0px 30px 90px;
310 | background-color: ${(props) => (props.current === "true" ? "#1e2125" : "#f8f8f8")};
311 | border: 1px solid ${(props) => (props.current === "true" ? "#404040" : "#eee")};
312 |
313 | &::-webkit-scrollbar {
314 | width: 11px;
315 | height: 11px;
316 | background: #ffffff;
317 | }
318 | &::-webkit-scrollbar-thumb {
319 | border-radius: 7px;
320 | background-color: #787878;
321 |
322 | &:hover {
323 | background-color: #444;
324 | }
325 | &:active {
326 | background-color: #444;
327 | }
328 | }
329 | &::-webkit-scrollbar-track {
330 | background-color: lightgray;
331 | }
332 | `;
333 |
334 | const LoginFormContent = styled.div`
335 | display: flex;
336 | flex-direction: column;
337 | justify-content: center;
338 | align-items: center;
339 | padding-top: 20px;
340 | padding-bottom: 20px;
341 | padding-left: 30px;
342 | padding-right: 35px;
343 | align-items: flex-start;
344 | `;
345 |
346 | const CloseButton = styled(FontAwesomeIcon)`
347 | position: absolute;
348 | top: 30px;
349 | right: 38px;
350 | font-size: 28px;
351 | cursor: pointer;
352 | color: gray;
353 |
354 | &:hover {
355 | color: ${(props) => (props.current === "true" ? "#DCDCDC" : "#303030")};
356 | }
357 | `;
358 |
359 | const PostingTweetFollowerContainer = styled.div`
360 | margin-top: 18px;
361 | `;
362 |
363 | const PostingTweetTitle = styled.h1`
364 | font-size: 20px;
365 | font-weight: bold;
366 | margin-bottom: 32px;
367 | `;
368 |
369 | const PostingTweetFollower = styled.div`
370 | display: flex;
371 | margin-bottom: 30px;
372 | `;
373 |
374 | const ModalContainer = styled(Modal)`
375 | height: 100vh;
376 | `;
377 |
378 | const ModalImage = styled.img`
379 | width: 560px;
380 | height: 340px;
381 | position: absolute;
382 | top: 50%;
383 | left: 50%;
384 | transform: translate(-50%, -50%);
385 | border-radius: 15px;
386 | `;
387 |
388 | const ModalCloseButton = styled(FontAwesomeIcon)`
389 | border: none;
390 | outline: none;
391 | cursor: pointer;
392 | padding: 8px 12px;
393 | color: white;
394 | border-radius: 50%;
395 | font-size: 25px;
396 | font-weight: bold;
397 | background-color: #a4b0be;
398 | margin-left: 20px;
399 | margin-top: 20px;
400 | `;
401 |
402 | const Tweet = ({ userObject, tweetObject, isOwner, createNotification, isDark }) => {
403 | // userObject는 현재 로그인한 유저, tweetObject는 해당 트윗을 작성한 유저
404 | // console.log("Tweet.js tweetObject", tweetObject);
405 | // console.log("Tweet.js userObject", userObject);
406 |
407 | const [isEditing, setIsEditing] = useState(false); // 현재 트윗을 수정 중인지 확인
408 | const [editingTweet, setEditingTweet] = useState(tweetObject.content); // 수정 중인 트윗 내용을 가져옴
409 | const [isLike, setIsLike] = useState(false); // 좋아요를 눌렀는지 체크(Local)
410 | const [isHeart, setIsHeart] = useState(tweetObject.likesArray.includes(userObject?.email)); // 좋아요를 눌렀는지 체크(DB)
411 | const [searchTweetLength, setSearchTweetsLength] = useState(0);
412 | const [searchTweet, setSearchTweets] = useState("");
413 | const [isFollower, setIsFollower] = useState(false);
414 | const [isSearchTweetAuthor, setSearchTweetAuthor] = useState("유저");
415 | const [modalIsOpen, setIsOpen] = useState(false);
416 | const [modalImageSrc, setModalImageSrc] = useState("");
417 |
418 | Modal.setAppElement("#root");
419 |
420 | const handleOpenModal = (event) => {
421 | const {
422 | target: { src },
423 | } = event;
424 |
425 | setModalImageSrc(src);
426 | setIsOpen(true);
427 | };
428 |
429 | const handleAfterOpenModal = () => {};
430 |
431 | const handleCloseModal = () => {
432 | setIsOpen(false);
433 | };
434 |
435 | const getTime = (time) => {
436 | const now = parseInt(time);
437 | const date = new Date(now);
438 | const day = ["일", "월", "화", "수", "목", "금", "토"];
439 | const getMonth = date.getMonth() + 1;
440 | const getDate = date.getDate();
441 | const getDay = day[date.getDay()];
442 | return `${getMonth}월 ${getDate}일 (${getDay})`;
443 | };
444 |
445 | // 트윗 수정 버튼
446 | const onSubmit = async (event) => {
447 | event.preventDefault();
448 |
449 | await firestoreService.collection("tweets").doc(`${tweetObject.documentId}`).update({
450 | content: editingTweet,
451 | });
452 |
453 | setIsEditing(false);
454 | createNotification("SuccessEditTweet");
455 | };
456 |
457 | const onChange = (event) => {
458 | const {
459 | target: { value },
460 | } = event;
461 |
462 | setEditingTweet(value);
463 | };
464 |
465 | const onEditTweet = () => {
466 | setIsEditing(true);
467 | setEditingTweet(tweetObject.content);
468 | };
469 |
470 | // 트윗 삭제 버튼 (아이콘)
471 | const onDeleteTweet = async () => {
472 | const booleanDeleteTweet = window.confirm("트윗을 삭제하시겠습니까?");
473 |
474 | if (booleanDeleteTweet) {
475 | // await firestoreService.doc(`${"tweets"}/${tweetObject.documentId}`).delete();
476 | await firestoreService.collection("tweets").doc(`${tweetObject.documentId}`).delete(); // Cloud Firestore(DB)에서 트윗 삭제
477 |
478 | if (tweetObject.fileDownloadUrl) {
479 | await storageService.refFromURL(tweetObject.fileDownloadUrl).delete(); // Storage에서 파일 삭제
480 | }
481 | }
482 |
483 | createNotification("SuccessDeleteTweet");
484 | };
485 |
486 | // 트윗 취소 버튼
487 | const onCancelTweet = () => {
488 | setEditingTweet(editingTweet);
489 | setIsEditing(false);
490 | };
491 |
492 | // 좋아요 버튼
493 | const handleLikeBtn = async () => {
494 | if (userObject === null) {
495 | createNotification("NotLogin");
496 | return;
497 | }
498 |
499 | const totalLikesArray = [userObject.email, ...tweetObject.likesArray];
500 | const checkTotalLikesArray = tweetObject.likesArray.includes(userObject.email);
501 |
502 | if (checkTotalLikesArray) {
503 | const filteredLikesArray = totalLikesArray.filter((value, index) => {
504 | return value !== userObject.email;
505 | });
506 |
507 | await firestoreService.collection("tweets").doc(`${tweetObject.documentId}`).update({
508 | likesArray: filteredLikesArray,
509 | clickLikes: false,
510 | });
511 |
512 | setIsLike(false);
513 | setIsHeart(false);
514 | return;
515 | }
516 |
517 | if (isLike === false) {
518 | await firestoreService
519 | .collection("tweets")
520 | .doc(`${tweetObject.documentId}`)
521 | .update({
522 | likesArray: [...new Set(totalLikesArray)],
523 | clickLikes: true,
524 | });
525 |
526 | setIsHeart(true);
527 | } else if (isLike === true) {
528 | const filteredLikesArray = totalLikesArray.filter((value, index) => {
529 | return value !== userObject.email;
530 | });
531 |
532 | await firestoreService.collection("tweets").doc(`${tweetObject.documentId}`).update({
533 | likesArray: filteredLikesArray,
534 | clickLikes: false,
535 | });
536 |
537 | setIsHeart(false);
538 | }
539 |
540 | setIsLike(!isLike);
541 | };
542 |
543 | const handleNothing = () => {};
544 |
545 | const handlePostingTweet = async (uid) => {
546 | await firestoreService
547 | .collection("tweets")
548 | .where("uid", "==", uid)
549 | .orderBy("createdAtTime", "desc")
550 | .onSnapshot((querySnapshot) => {
551 | const querySnapshotSize = querySnapshot.size;
552 | const queryDocumentSnapshotObjectArray = querySnapshot.docs.map((queryDocumentSnapshot) => ({
553 | id: queryDocumentSnapshot.id,
554 | documentId: queryDocumentSnapshot.id,
555 | ...queryDocumentSnapshot.data(),
556 | }));
557 |
558 | setSearchTweetsLength(querySnapshotSize);
559 | setSearchTweets(queryDocumentSnapshotObjectArray);
560 | setSearchTweetAuthor(queryDocumentSnapshotObjectArray[0]?.displayName);
561 | });
562 | setIsFollower(!isFollower);
563 | };
564 |
565 | const handleCloseFollower = () => {
566 | setIsFollower(false);
567 | };
568 |
569 | return (
570 |
575 | {/* 트윗을 현재 수정중인지 확인 */}
576 | {isEditing ? (
577 | <>
578 | {isOwner && (
579 | <>
580 |
581 |
582 |
583 |
584 |
585 | {tweetObject.displayName}
586 | {tweetObject.email}
587 | ·
588 | {getTime(tweetObject.createdAtTime)}
589 |
590 |
591 | {isOwner && (
592 | <>
593 |
594 | 수정
595 |
596 |
597 | 취소
598 |
599 | >
600 | )}
601 |
602 |
603 |
611 | {tweetObject.fileDownloadUrl && }
612 |
613 |
614 | {tweetObject.likesArray.length}
615 |
616 |
617 |
618 | >
619 | )}
620 | >
621 | ) : (
622 | <>
623 | handlePostingTweet(tweetObject.uid))}
626 | >
627 |
628 |
629 | handlePostingTweet(tweetObject.uid))}>
630 | {tweetObject.displayName}
631 | {tweetObject.email}
632 | ·
633 | {getTime(tweetObject.createdAtTime)}
634 |
635 |
636 | {isOwner && (
637 | <>
638 |
639 |
640 |
641 |
642 |
643 |
644 | >
645 | )}
646 |
647 |
648 | {tweetObject.content}
649 | {tweetObject.fileDownloadUrl && (
650 |
651 | )}
652 |
653 |
654 |
655 | {isHeart ? (
656 |
664 |
665 |
666 |
667 |
668 | ) : (
669 |
677 |
678 |
679 |
680 |
681 | )}
682 | {tweetObject.likesArray.length}
683 |
684 |
691 |
692 |
693 |
694 |
695 |
702 |
703 |
704 |
705 |
706 |
713 |
714 |
715 |
716 |
717 |
718 |
719 |
720 | >
721 | )}
722 |
723 | {/* 팔로워 폼 */}
724 | {isFollower ? (
725 |
726 |
727 |
728 |
729 |
730 | {isSearchTweetAuthor && isSearchTweetAuthor}님이 작성한 트윗 ({searchTweetLength})
731 |
732 | {searchTweet &&
733 | searchTweet.map((tweetObject) => (
734 |
735 |
736 |
737 |
738 |
739 | {tweetObject.displayName}
740 | {tweetObject.email}
741 | ·
742 | {getTime(tweetObject.createdAtTime)}
743 |
744 |
745 | {tweetObject.content}
746 | {tweetObject.fileDownloadUrl && }
747 |
748 |
749 |
750 |
757 |
758 |
759 |
760 |
761 | {tweetObject.likesArray.length}
762 |
763 |
770 |
771 |
772 |
773 |
774 |
781 |
782 |
783 |
784 |
785 |
792 |
793 |
794 |
795 |
796 |
797 |
798 |
799 |
800 | ))}
801 |
802 |
803 |
804 | ) : null}
805 |
806 |
807 |
808 |
809 |
810 |
811 | );
812 | };
813 |
814 | Tweet.propTypes = {
815 | userObject: PropTypes.object,
816 | tweetObject: PropTypes.object.isRequired,
817 | isOwner: PropTypes.bool.isRequired,
818 | createNotification: PropTypes.func.isRequired,
819 | isDark: PropTypes.bool.isRequired,
820 | };
821 |
822 | export default Tweet;
823 |
--------------------------------------------------------------------------------
/src/components/TweetForm.js:
--------------------------------------------------------------------------------
1 | import { useRef, useState } from "react";
2 | import { authService, firestoreService, storageService } from "firebaseConfiguration";
3 | import styled from "styled-components";
4 | import PropTypes from "prop-types";
5 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
6 | import { faTimes } from "@fortawesome/free-solid-svg-icons";
7 | import Picker from "emoji-picker-react";
8 |
9 | const TweetFormContainer = styled.form``;
10 |
11 | const TweetFormTextContainer = styled.div`
12 | position: relative;
13 | `;
14 |
15 | const TweetFormTextInput = styled.input`
16 | width: 100%;
17 | border: none;
18 | outline: none;
19 | padding: 12px 0px;
20 | padding-left: 4px;
21 | padding-right: 30px;
22 | padding-bottom: 18px;
23 | margin-bottom: 15px;
24 | box-sizing: border-box;
25 | font-size: 18px;
26 | border-radius: 4px;
27 | color: #989898;
28 | background-color: ${(props) => (props.current === "true" ? "#1e2125" : "#f8f8f8")};
29 |
30 | &::placeholder {
31 | color: #989898;
32 | }
33 | `;
34 |
35 | const TweetFormImageInput = styled.input``;
36 |
37 | const FileDataContainer = styled.div`
38 | position: relative;
39 | `;
40 |
41 | const FileData = styled.img`
42 | width: 490px;
43 | height: 280px;
44 | border-radius: 15px;
45 |
46 | @media (max-width: 768px) {
47 | width: 100%;
48 | height: 200px;
49 | }
50 | `;
51 |
52 | const FileDataButton = styled.button``;
53 |
54 | const IconDataCancelContainer = styled(FontAwesomeIcon)`
55 | position: absolute;
56 | top: 5px;
57 | left: 6px;
58 | font-size: 17px;
59 | padding: 7px 9px;
60 | color: white;
61 | background-color: rgba(0, 0, 0, 0.7);
62 | border-radius: 50%;
63 | `;
64 |
65 | const TweetFormImageContainer = styled.div`
66 | display: flex;
67 | align-items: center;
68 | justify-content: flex-start;
69 | margin-top: 10px;
70 | position: relative;
71 | `;
72 |
73 | const TweetFormImageLabel = styled.label`
74 | margin-top: 4px;
75 | `;
76 |
77 | const IconSVG = styled.svg`
78 | fill: #bebebe;
79 | height: 21px;
80 | cursor: pointer;
81 | border-radius: 50%;
82 | padding: 7px;
83 |
84 | &:hover {
85 | fill: ${(props) => (props.current === "true" ? "#1DA1F2" : "#bebebe")};
86 | background-color: ${(props) => (props.current === "true" ? "#1e2125" : "#e6f3ff")};
87 | }
88 | `;
89 |
90 | const IconG = styled.g``;
91 |
92 | const IconPath = styled.path``;
93 |
94 | const IconCircle = styled.circle``;
95 |
96 | const TweetFormSubmit = styled.input`
97 | border: none;
98 | outline: none;
99 | cursor: pointer;
100 | padding: 10px 15px;
101 | color: white;
102 | border-radius: 30px;
103 | font-size: 15px;
104 | font-weight: bold;
105 | background-color: #98cff8;
106 | margin-left: auto;
107 | `;
108 |
109 | const PickerContainer = styled(Picker)``;
110 |
111 | const TweetForm = ({ userObject, createNotification, isDark }) => {
112 | const [tweet, setTweet] = useState("");
113 | const [fileDataUrl, setFileDataUrl] = useState("");
114 | const [fileName, setFileName] = useState("");
115 | const fileImageInput = useRef();
116 | const textInput = useRef();
117 | const inputTweet = useRef();
118 | const [isEmoji, setIsEmoji] = useState(false);
119 |
120 | // 트윗하기 버튼
121 | const onSubmit = async (event) => {
122 | event.preventDefault();
123 |
124 | if (userObject === null) {
125 | createNotification("NotLogin");
126 | return;
127 | }
128 |
129 | let fileDownloadUrl = "";
130 | const currentUserObject = authService.currentUser;
131 |
132 | if (fileDataUrl !== "") {
133 | // 1. 파일이 업로드되서 저장될 버킷 내부의 래퍼런스 경로를 생성
134 | const fileReference = storageService.ref().child(`${userObject.email}/tweet/${fileName}`);
135 |
136 | // 2. 파일 데이터를 버킷 내부의 래퍼런스 경로로 전달 (파일을 버킷에 업로드)
137 | const uploadTask = await fileReference.putString(fileDataUrl, "data_url");
138 |
139 | // 3. 버킷 내부의 래퍼런스에 있는 파일에 대한 DownloadURL을 받음
140 | fileDownloadUrl = await uploadTask.ref.getDownloadURL();
141 | }
142 |
143 | await firestoreService.collection("tweets").add({
144 | uid: currentUserObject.uid,
145 | displayName: currentUserObject.displayName,
146 | email: currentUserObject.email,
147 | emailVerified: currentUserObject.emailVerified,
148 | photoURL: currentUserObject.photoURL,
149 | creationTime: currentUserObject.metadata.a,
150 | lastSignInTime: currentUserObject.metadata.b,
151 | content: tweet,
152 | createdAtTime: Date.now(),
153 | createdAtDate: new Date().toLocaleDateString(),
154 | fileDownloadUrl,
155 | likesArray: [],
156 | clickLikes: false,
157 | });
158 |
159 | fileImageInput.current.value = "";
160 | setTweet("");
161 | setFileDataUrl("");
162 | setIsEmoji(false);
163 | createNotification("SuccessPostTweet");
164 | };
165 |
166 | const onChange = (event) => {
167 | const {
168 | target: { value },
169 | } = event;
170 |
171 | if (value) {
172 | inputTweet.current.style.backgroundColor = "#1DA1F2";
173 | } else {
174 | inputTweet.current.style.backgroundColor = "#98cff8";
175 | }
176 |
177 | setTweet(value);
178 | setIsEmoji(false);
179 | };
180 |
181 | // 파일 첨부 버튼
182 | const onFileChange = (event) => {
183 | const {
184 | target: { files },
185 | } = event;
186 | const uploadFile = files[0];
187 | const uploadFileName = uploadFile?.name;
188 | const fileReader = new FileReader();
189 |
190 | if (fileReader && uploadFile !== undefined && uploadFile !== null) {
191 | fileReader.onload = (event) => {
192 | const {
193 | target: { result },
194 | } = event;
195 |
196 | setFileDataUrl(result);
197 | };
198 |
199 | fileReader.readAsDataURL(uploadFile);
200 | setFileName(`${uploadFileName}_${Date.now()}`);
201 | }
202 | };
203 |
204 | // 이미지 첨부 후 닫기 버튼
205 | const onCancelClick = () => {
206 | setFileDataUrl("");
207 | setTweet("");
208 | fileImageInput.current.value = "";
209 | };
210 |
211 | // input에 이모지 넣기
212 | const onEmojiClick = (event, emojiObject) => {
213 | const textInputValue = textInput.current.value;
214 | const inputValue = textInputValue + emojiObject.emoji;
215 |
216 | setTweet(inputValue);
217 | };
218 |
219 | // 이모지 버튼 클릭
220 | const onClickEmoji = () => {
221 | setIsEmoji(!isEmoji);
222 | };
223 |
224 | return (
225 |
226 |
227 |
237 |
238 | {fileDataUrl && (
239 |
240 |
241 |
242 |
243 |
244 |
245 | )}
246 |
247 |
248 |
249 |
250 |
256 |
257 |
258 |
259 |
260 |
261 |
262 |
263 |
271 |
272 |
273 |
274 |
275 |
276 |
277 |
278 | {isEmoji ? : null}
279 |
286 |
287 |
288 |
289 |
290 |
291 |
292 |
293 |
299 |
300 |
301 |
302 |
303 |
309 |
310 |
311 |
312 |
313 |
314 |
315 |
316 |
317 | );
318 | };
319 |
320 | TweetForm.propTypes = {
321 | userObject: PropTypes.object,
322 | createNotification: PropTypes.func.isRequired,
323 | isDark: PropTypes.bool.isRequired,
324 | };
325 |
326 | export default TweetForm;
327 |
--------------------------------------------------------------------------------
/src/firebaseConfiguration.js:
--------------------------------------------------------------------------------
1 | import firebase from "firebase/app";
2 | import "firebase/auth";
3 | import "firebase/firestore";
4 | import "firebase/storage";
5 |
6 | // Firebase Configuration
7 | const firebaseConfig = {
8 | apiKey: process.env.REACT_APP_API_KEY,
9 | authDomain: process.env.REACT_APP_AUTH_DOMAIN,
10 | projectId: process.env.REACT_APP_PROJECT_ID,
11 | storageBucket: process.env.REACT_APP_STORAGE_BUCKET,
12 | messagingSenderId: process.env.REACT_APP_MESSAGING_SENDER_ID,
13 | appId: process.env.REACT_APP_APP_ID,
14 | measurementId: process.env.REACT_APP_MEASUREMENT_ID,
15 | };
16 |
17 | // Initialize Firebase
18 | firebase.initializeApp(firebaseConfig);
19 |
20 | export const firebaseApp = firebase; // firebase
21 | export const authService = firebase.auth(); // Authentication
22 | export const firestoreService = firebase.firestore(); // Firestore Database
23 | export const storageService = firebase.storage(); // Storage
24 |
--------------------------------------------------------------------------------
/src/images/apple-logo.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GitHubGW/twitter-clone/faa3079286b8349d4a37f1101a2dff27e2e6ef64/src/images/apple-logo.jpeg
--------------------------------------------------------------------------------
/src/images/apple-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GitHubGW/twitter-clone/faa3079286b8349d4a37f1101a2dff27e2e6ef64/src/images/apple-logo.png
--------------------------------------------------------------------------------
/src/images/coding-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GitHubGW/twitter-clone/faa3079286b8349d4a37f1101a2dff27e2e6ef64/src/images/coding-logo.png
--------------------------------------------------------------------------------
/src/images/github-logo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/images/google-logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/src/images/nasa-logo.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GitHubGW/twitter-clone/faa3079286b8349d4a37f1101a2dff27e2e6ef64/src/images/nasa-logo.jpeg
--------------------------------------------------------------------------------
/src/images/nomadcoder-logo-black.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GitHubGW/twitter-clone/faa3079286b8349d4a37f1101a2dff27e2e6ef64/src/images/nomadcoder-logo-black.jpeg
--------------------------------------------------------------------------------
/src/images/nomadcoder-logo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/images/tesla-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GitHubGW/twitter-clone/faa3079286b8349d4a37f1101a2dff27e2e6ef64/src/images/tesla-logo.png
--------------------------------------------------------------------------------
/src/images/user.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GitHubGW/twitter-clone/faa3079286b8349d4a37f1101a2dff27e2e6ef64/src/images/user.png
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ReactDOM from "react-dom";
3 | import App from "components/App";
4 |
5 | ReactDOM.render( , document.getElementById("root"));
6 |
--------------------------------------------------------------------------------
/src/routes/Authentication.js:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { firebaseApp, authService } from "firebaseConfiguration";
3 | import styled from "styled-components";
4 | import PropTypes from "prop-types";
5 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
6 | import { faTwitter, faGithub } from "@fortawesome/free-brands-svg-icons";
7 | import { faTimes } from "@fortawesome/free-solid-svg-icons";
8 | import googleLogo from "../images/google-logo.svg";
9 | import { useHistory } from "react-router-dom";
10 |
11 | const LoginFormContainer = styled.div`
12 | position: fixed;
13 | top: 50%;
14 | left: 50%;
15 | transform: translate(-50%, -50%);
16 | width: 420px;
17 | height: 580px;
18 | z-index: 10;
19 | background-color: white;
20 | border-radius: 20px;
21 | z-index: 100;
22 | box-shadow: rgba(0, 0, 0, 0.4) 0px 30px 90px;
23 | background-color: ${(props) => (props.current === "true" ? "#1e2125" : "#f8f8f8")};
24 | border: 1px solid ${(props) => (props.current === "true" ? "#404040" : "#eee")};
25 | `;
26 |
27 | const PWEmailFormContainer = styled.div`
28 | position: fixed;
29 | top: 50%;
30 | left: 50%;
31 | transform: translate(-50%, -50%);
32 | width: 420px;
33 | height: 380px;
34 | z-index: 10;
35 | background-color: white;
36 | border-radius: 20px;
37 | z-index: 100;
38 | box-shadow: rgba(0, 0, 0, 0.4) 0px 30px 90px;
39 | background-color: ${(props) => (props.current === "true" ? "#1e2125" : "#f8f8f8")};
40 | border: 1px solid ${(props) => (props.current === "true" ? "#404040" : "#eee")};
41 | `;
42 |
43 | const LoginFormContent = styled.div`
44 | display: flex;
45 | flex-direction: column;
46 | justify-content: center;
47 | align-items: center;
48 | padding: 15px;
49 | padding-top: 45px;
50 | `;
51 |
52 | const IconTwitter = styled(FontAwesomeIcon)`
53 | font-size: 40px;
54 | color: var(--twitter-color);
55 | cursor: pointer;
56 | `;
57 |
58 | const LoginFormTitle = styled.h1`
59 | font-size: 20px;
60 | font-weight: bold;
61 | margin-top: 30px;
62 | margin-bottom: 10px;
63 | `;
64 |
65 | const LoginFormTag = styled.form`
66 | display: flex;
67 | flex-direction: column;
68 | width: 80%;
69 | `;
70 |
71 | const LoginInputTag = styled.input`
72 | border: none;
73 | outline: none;
74 | padding: 15px;
75 | padding-bottom: 15px;
76 | padding-top: 27px;
77 | background-color: #f5f5f5;
78 | margin-top: 10px;
79 | font-size: 16px;
80 | border-radius: 5px;
81 | position: relative;
82 |
83 | &:focus {
84 | background-color: #e8e8e8;
85 | }
86 |
87 | &::placeholder {
88 | font-size: 14px;
89 | position: absolute;
90 | top: 10px;
91 | left: 15px;
92 | }
93 | `;
94 |
95 | const LoginSubmitTag = styled.input`
96 | border: none;
97 | outline: none;
98 | background-color: #98cff8;
99 | padding: 12px;
100 | color: white;
101 | border-radius: 30px;
102 | font-size: 16px;
103 | font-weight: bold;
104 | cursor: pointer;
105 |
106 | &:hover {
107 | background-color: var(--twitter-color);
108 | }
109 | `;
110 |
111 | const SocialLoginContainer = styled.div`
112 | display: flex;
113 | flex-direction: column;
114 | justify-content: center;
115 | width: 80%;
116 | `;
117 |
118 | const RegisterButton = styled.button`
119 | color: var(--twitter-color);
120 | margin-top: 20px;
121 | font-size: 16px;
122 | font-weight: bold;
123 |
124 | &:hover {
125 | text-decoration: underline;
126 | }
127 | `;
128 |
129 | const CloseButton = styled(FontAwesomeIcon)`
130 | position: absolute;
131 | top: 12px;
132 | left: 12px;
133 | font-size: 24px;
134 | cursor: pointer;
135 | color: gray;
136 |
137 | &:hover {
138 | color: ${(props) => (props.current === "true" ? "#DCDCDC" : "#303030")};
139 | }
140 | `;
141 |
142 | const GoogleLogin = styled.button`
143 | border: none;
144 | outline: none;
145 | background-color: #ffffff;
146 | border: 1px solid #e0e0e0;
147 | padding: 11px;
148 | color: black;
149 | border-radius: 30px;
150 | font-size: 16px;
151 | font-weight: bold;
152 | cursor: pointer;
153 | margin-top: 12px;
154 | display: flex;
155 | align-items: center;
156 | justify-content: center;
157 |
158 | &:hover {
159 | background-color: #e0e0e0;
160 | }
161 | `;
162 |
163 | const GithubLogin = styled.button`
164 | border: none;
165 | outline: none;
166 | background-color: #303030;
167 | padding: 11px;
168 | color: white;
169 | border-radius: 30px;
170 | font-size: 16px;
171 | font-weight: bold;
172 | cursor: pointer;
173 | margin-top: 12px;
174 | display: flex;
175 | align-items: center;
176 | justify-content: center;
177 |
178 | &:hover {
179 | background-color: #0d1118;
180 | }
181 | `;
182 |
183 | const IconGoogle = styled.img`
184 | width: 18px;
185 | margin-right: 5px;
186 | margin-bottom: 1px;
187 | `;
188 |
189 | const IconGithub = styled(FontAwesomeIcon)`
190 | font-size: 21px;
191 | margin-right: 5px;
192 | margin-bottom: 1px;
193 | `;
194 |
195 | const ErrorMessage = styled.h3`
196 | font-size: 13px;
197 | margin-top: 8px;
198 | margin-bottom: 12px;
199 | color: #eb4d4b;
200 | font-weight: bold;
201 | `;
202 |
203 | const MenuLoginForm = styled.div`
204 | margin-bottom: 10px;
205 | margin-top: 17px;
206 | display: flex;
207 | justify-content: flex-start;
208 | align-items: center;
209 | `;
210 |
211 | const MenuLoginButton = styled.button`
212 | border: none;
213 | outline: none;
214 | cursor: pointer;
215 | padding: 10px 12px;
216 | color: white;
217 | border-radius: 30px;
218 | font-size: 13px;
219 | font-weight: bold;
220 | background-color: var(--twitter-color);
221 | margin-right: 5px;
222 |
223 | &:hover {
224 | background-color: var(--twitter-dark-color);
225 | }
226 | `;
227 |
228 | const MenuLogoutButton = styled.button`
229 | border: none;
230 | outline: none;
231 | cursor: pointer;
232 | padding: 10px 12px;
233 | color: white;
234 | border-radius: 30px;
235 | font-size: 13px;
236 | font-weight: bold;
237 | background-color: var(--twitter-color);
238 | margin-right: 5px;
239 |
240 | &:hover {
241 | background-color: var(--twitter-dark-color);
242 | }
243 | `;
244 |
245 | const DarkModeButton = styled.button`
246 | font-size: 30px;
247 | margin-right: 3px;
248 | margin-left: auto;
249 | `;
250 |
251 | const ChangePasswordBtn = styled.button`
252 | border: none;
253 | outline: none;
254 | cursor: pointer;
255 | padding: 10px 12px;
256 | color: white;
257 | border-radius: 30px;
258 | font-size: 13px;
259 | font-weight: bold;
260 | background-color: #a4b0be;
261 |
262 | margin-right: 5px;
263 |
264 | &:hover {
265 | background-color: #57606f;
266 | }
267 | `;
268 |
269 | const ChangeEmailBtn = styled.button`
270 | border: none;
271 | outline: none;
272 | cursor: pointer;
273 | padding: 10px 12px;
274 | color: white;
275 | border-radius: 30px;
276 | font-size: 13px;
277 | font-weight: bold;
278 | background-color: #747d8c;
279 |
280 | &:hover {
281 | background-color: #2f3542;
282 | }
283 | `;
284 |
285 | const Authentication = ({ userObject, createNotification, isDark, changeTheme }) => {
286 | const history = useHistory();
287 | const [email, setEmail] = useState(""); // 유저 이메일
288 | const [password, setPassword] = useState(""); // 유저 비밀번호
289 | const [displayName, setDisplayName] = useState(""); // 유저 닉네임
290 | const [newPassword, setNewPassword] = useState(""); // 새로운 비밀번호
291 | const [newEmail, setNewEmail] = useState(""); // 새로운 이메일
292 | const [isAccount] = useState(false); // 계정 존재 여부 체크 (true: 계정있음, false: 계정없음)
293 | const [error, setError] = useState(null); // 로그인 또는 회원가입 에러메시지
294 | const [isLoginForm, setIsLoginForm] = useState(false); // 로그인 폼
295 | const [isRegisterForm, setIsRegisterForm] = useState(false); // 회원가입 폼
296 | const [isChangePasswordForm, setIsChangePasswordForm] = useState(false); // 비밀번호 변경 폼
297 | const [isChangeEmailForm, setIsChangeEmailForm] = useState(false); // 이메일 변경 폼
298 |
299 | // 이메일, 비밀번호 로그인
300 | const onSubmit = async (event) => {
301 | event.preventDefault();
302 |
303 | try {
304 | await authService.signInWithEmailAndPassword(email, password); // 로그인
305 | createNotification("SuccessLogin");
306 | setIsLoginForm(!isLoginForm);
307 | } catch (error) {
308 | console.log(error);
309 | setError(error.message);
310 | createNotification("FailLogin");
311 | }
312 | };
313 |
314 | const onChange = (event) => {
315 | const {
316 | target: { name, value },
317 | } = event;
318 |
319 | if (name === "emailInput") {
320 | setEmail(value);
321 | } else if (name === "passwordInput") {
322 | setPassword(value);
323 | } else if (name === "displayNameInput") {
324 | setDisplayName(value);
325 | }
326 | };
327 |
328 | // 이메일, 비밀번호로 계정 생성후 로그인
329 | const onClickRegister = async (event) => {
330 | event.preventDefault();
331 |
332 | try {
333 | if (!isAccount) {
334 | await authService.createUserWithEmailAndPassword(email, password); // 이메일, 비밀번호로 계정 생성
335 | await authService.currentUser?.updateProfile({
336 | displayName,
337 | });
338 | createNotification("SuccessRegister");
339 | setIsRegisterForm(!isRegisterForm);
340 | }
341 | } catch (error) {
342 | console.log(error);
343 | setError(error.message);
344 | } finally {
345 | }
346 | };
347 |
348 | // 소셜 로그인
349 | const onClickSocialLogin = async (event) => {
350 | const {
351 | target: { name },
352 | } = event;
353 |
354 | if (name === "googleLogin") {
355 | try {
356 | const googleProvider = new firebaseApp.auth.GoogleAuthProvider();
357 | await authService.signInWithPopup(googleProvider);
358 | setIsLoginForm(!isLoginForm);
359 | createNotification("SuccessGoogleLogin");
360 | } catch (error) {
361 | console.log(error);
362 | setError(error.message);
363 | createNotification("FailGoogleLogin");
364 | }
365 | } else if (name === "githubLogin") {
366 | try {
367 | const githubProvider = new firebaseApp.auth.GithubAuthProvider();
368 | await authService.signInWithPopup(githubProvider);
369 | setIsLoginForm(!isLoginForm);
370 | createNotification("SuccessGithubLogin");
371 | } catch (error) {
372 | console.log(error);
373 | setError(error.message);
374 | createNotification("FailGithubLogin");
375 | }
376 | }
377 | };
378 |
379 | const onChangePassword = (event) => {
380 | const {
381 | target: { value },
382 | } = event;
383 |
384 | setNewPassword(value);
385 | };
386 |
387 | // 비밀번호 변경
388 | const onClickChangePassword = async (event) => {
389 | event.preventDefault();
390 |
391 | try {
392 | await authService.currentUser.updatePassword(newPassword);
393 | setIsChangePasswordForm(false);
394 | createNotification("SuccessChangePassword");
395 | } catch (error) {
396 | console.log(error);
397 | setError(error.message);
398 | createNotification("FailChangePassword");
399 | } finally {
400 | history.push("/");
401 | }
402 | };
403 |
404 | const onChangeEmail = (event) => {
405 | const {
406 | target: { value },
407 | } = event;
408 |
409 | setNewEmail(value);
410 | };
411 |
412 | // 이메일 변경
413 | const onClickChangeEmail = async (event) => {
414 | event.preventDefault();
415 |
416 | try {
417 | await authService.currentUser.updateEmail(newEmail);
418 | setIsChangeEmailForm(false);
419 | createNotification("SuccessChangeEmail");
420 | } catch (error) {
421 | console.log(error);
422 | setError(error.message);
423 | createNotification("FailChangeEmail");
424 | } finally {
425 | history.push("/");
426 | }
427 | };
428 |
429 | // 홈화면 로그인 버튼
430 | const handleMainLogin = () => {
431 | setIsRegisterForm(false);
432 | setIsLoginForm(!isLoginForm);
433 | };
434 |
435 | // 홈화면 회원가입 버튼
436 | const handleMainRegister = () => {
437 | setIsLoginForm(false);
438 | setIsRegisterForm(!isRegisterForm);
439 | };
440 |
441 | // 로그인/회원가입 폼 닫기 버튼
442 | const handleCloseButton = () => {
443 | setIsLoginForm(false);
444 | setIsRegisterForm(false);
445 | setIsChangePasswordForm(false);
446 | setIsChangeEmailForm(false);
447 | };
448 |
449 | // 홈화면 로그아웃 버튼
450 | const onClickLogOut = async () => {
451 | const currentUser = authService.currentUser;
452 |
453 | if (currentUser) {
454 | await authService.signOut();
455 | createNotification("SuccessLogout");
456 | history.push("/");
457 | return;
458 | }
459 | };
460 |
461 | // 회원가입 폼으로 이동
462 | const gotoRegisterForm = () => {
463 | setIsRegisterForm(true);
464 | setIsLoginForm(false);
465 | setIsChangePasswordForm(false);
466 | setIsChangeEmailForm(false);
467 | };
468 |
469 | // 로그인 폼으로 이동
470 | const gotoLoginForm = () => {
471 | setIsLoginForm(true);
472 | setIsRegisterForm(false);
473 | setIsChangePasswordForm(false);
474 | setIsChangeEmailForm(false);
475 | };
476 |
477 | // 비밀번호 변경 폼으로 이동
478 | const gotoPasswordForm = () => {
479 | setIsChangePasswordForm(true);
480 | setIsRegisterForm(false);
481 | setIsLoginForm(false);
482 | setIsChangeEmailForm(false);
483 | };
484 |
485 | // 이메일 변경 폼으로 이동
486 | const gotoEmailForm = () => {
487 | setIsChangeEmailForm(true);
488 | setIsChangePasswordForm(false);
489 | setIsRegisterForm(false);
490 | setIsLoginForm(false);
491 | };
492 |
493 | /*
494 | // 회원 탈퇴
495 | const onUnRegister = async () => {
496 | try {
497 | await authService.currentUser.delete();
498 | } catch (error) {
499 | console.log(error);
500 | } finally {
501 | history.push("/");
502 | }
503 | };
504 | */
505 |
506 | /*
507 | // 이메일 인증 후 새로운 이메일로 변경
508 | const onUpdateNewEmail = async () => {
509 | try {
510 | await authService.currentUser.verifyBeforeUpdateEmail("kowonp@gmail.com");
511 | } catch (error) {
512 | console.log(error);
513 | } finally {
514 | history.push("/");
515 | }
516 | };
517 | */
518 |
519 | return (
520 | <>
521 | {/* 홈화면 메뉴 */}
522 |
523 | {userObject === null ? (
524 | <>
525 |
526 | 로그인
527 |
528 |
529 | 회원가입
530 |
531 | >
532 | ) : (
533 | <>
534 |
535 | 로그아웃
536 |
537 |
538 | 비밀번호 변경
539 |
540 |
541 | 이메일 변경
542 |
543 | >
544 | )}
545 |
546 | {isDark ? "🌙" : "🌞"}
547 |
548 |
549 |
550 | {/* 로그인 폼 */}
551 | {isLoginForm ? (
552 | <>
553 |
554 |
555 |
556 | 트위터 로그인
557 |
558 |
559 |
560 | {error && error}
561 |
562 |
563 |
564 |
565 |
566 | 구글 로그인
567 |
568 |
569 |
570 | 깃허브 로그인
571 |
572 |
573 | 트위터 회원가입
574 |
575 |
576 |
577 |
578 |
579 | >
580 | ) : null}
581 |
582 | {/* 회원가입 폼 */}
583 | {isRegisterForm ? (
584 | <>
585 |
586 |
587 |
588 | 트위터 회원가입
589 |
590 |
591 |
592 |
593 | {error && error}
594 |
595 |
596 |
597 |
598 | 트위터 로그인
599 |
600 |
601 |
602 |
603 |
604 | >
605 | ) : null}
606 |
607 | {/* 비밀번호 변경 폼 */}
608 | {isChangePasswordForm ? (
609 | <>
610 |
611 |
612 |
613 | 트위터 비밀번호 변경
614 |
615 |
616 | {error && error}
617 |
618 |
619 |
620 |
621 |
622 |
623 |
624 | >
625 | ) : null}
626 |
627 | {/* 이메일 변경 폼 */}
628 | {isChangeEmailForm ? (
629 | <>
630 |
631 |
632 |
633 | 트위터 이메일 변경
634 |
635 |
636 | {error && error}
637 |
638 |
639 |
640 |
641 |
642 |
643 |
644 | >
645 | ) : null}
646 | >
647 | );
648 | };
649 |
650 | Authentication.propTypes = {
651 | userObject: PropTypes.object,
652 | createNotification: PropTypes.func.isRequired,
653 | isDark: PropTypes.bool.isRequired,
654 | changeTheme: PropTypes.func.isRequired,
655 | };
656 |
657 | export default Authentication;
658 |
--------------------------------------------------------------------------------
/src/routes/Home.js:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef, useState } from "react";
2 | import { Link, useHistory } from "react-router-dom";
3 | import { firestoreService } from "firebaseConfiguration";
4 | import styled from "styled-components";
5 | import PropTypes from "prop-types";
6 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
7 | import { faTwitter } from "@fortawesome/free-brands-svg-icons";
8 | import { faEnvelope, faBookmark, faListAlt } from "@fortawesome/free-regular-svg-icons";
9 | import { faHome, faEllipsisH, faCog, faSearch, faArrowCircleUp, faTimes, faUserTag, faUser } from "@fortawesome/free-solid-svg-icons";
10 | import { Helmet } from "react-helmet";
11 | import Tweet from "components/Tweet";
12 | import TweetForm from "components/TweetForm";
13 | import Profile from "./Profile";
14 | import Authentication from "./Authentication";
15 | import userImage from "images/user.png";
16 | import nomadCoderImage from "images/nomadcoder-logo-black.jpeg";
17 | import appleImage from "images/apple-logo.png";
18 | import nasaImage from "images/nasa-logo.jpeg";
19 | import codingImage from "images/coding-logo.png";
20 | import _ from "underscore";
21 |
22 | const Container = styled.div`
23 | width: 1260px;
24 | max-width: 1260px;
25 | display: flex;
26 |
27 | @media (max-width: 768px) {
28 | width: 100%;
29 | height: auto;
30 | }
31 | `;
32 |
33 | const LeftContainerParent = styled.div`
34 | width: 280px;
35 |
36 | @media (max-width: 768px) {
37 | display: none;
38 | }
39 | `;
40 |
41 | const CenterContainerParent = styled.div`
42 | width: 590px;
43 |
44 | @media (max-width: 768px) {
45 | width: 100%;
46 | }
47 | `;
48 |
49 | const RightContainerParent = styled.div`
50 | width: 330px;
51 |
52 | @media (max-width: 768px) {
53 | display: none;
54 | }
55 | `;
56 |
57 | const LeftContainer = styled.div`
58 | width: 280px;
59 | height: 100vh;
60 | display: flex;
61 | flex-direction: column;
62 | justify-content: space-between;
63 | position: fixed;
64 | padding-right: 20px;
65 | box-sizing: border-box;
66 | padding-top: 5px;
67 | padding-bottom: 15px;
68 | border-right: 1px solid ${(props) => (props.current === "true" ? "#1e2125" : "#eee")};
69 | `;
70 |
71 | const MenuContainer = styled.div``;
72 |
73 | const MenuImage = styled.div`
74 | margin-left: 5px;
75 | margin-bottom: 15px;
76 | display: inline-block;
77 | `;
78 |
79 | const IconTwitterContainer = styled(FontAwesomeIcon)`
80 | font-size: 30px;
81 | color: var(--twitter-color);
82 | cursor: pointer;
83 | border-radius: 50%;
84 | padding: 10px;
85 |
86 | &:hover {
87 | background-color: ${(props) => (props.current === "true" ? "#1e2125" : "#e6f3ff")};
88 | }
89 | `;
90 |
91 | const MenuNav = styled.ul``;
92 |
93 | const MenuList = styled(Link)`
94 | margin-bottom: 8px;
95 | display: inline-block;
96 | margin-right: 50px;
97 | align-items: center;
98 | padding: 12px 15px;
99 | padding-right: 25px;
100 | border-radius: 50px;
101 | box-sizing: border-box;
102 | cursor: pointer;
103 |
104 | &:link {
105 | color: inherit;
106 | }
107 | &:visited {
108 | color: inherit;
109 | }
110 | &:hover {
111 | background-color: ${(props) => (props.current === "true" ? "#1e2125" : "#eeeeee")};
112 | }
113 | `;
114 |
115 | const MenuListSpan = styled.span`
116 | margin-bottom: 8px;
117 | display: inline-block;
118 | margin-right: 50px;
119 | align-items: center;
120 | padding: 12px 15px;
121 | padding-right: 25px;
122 | border-radius: 50px;
123 | box-sizing: border-box;
124 | cursor: pointer;
125 |
126 | &:link {
127 | color: inherit;
128 | }
129 | &:visited {
130 | color: inherit;
131 | }
132 | &:hover {
133 | background-color: ${(props) => (props.current === "true" ? "#1e2125" : "#eeeeee")};
134 | }
135 | `;
136 |
137 | const IconContainer = styled(FontAwesomeIcon)`
138 | width: 30px !important;
139 | display: inline-block;
140 | font-size: 24px;
141 | `;
142 |
143 | const IconText = styled.span`
144 | display: inline-block;
145 | font-size: 20px;
146 | margin-left: 20px;
147 | `;
148 |
149 | const MenuButton = styled.button`
150 | margin-top: 15px;
151 | padding: 17px 80px;
152 | background-color: var(--twitter-color);
153 | color: white;
154 | border-radius: 30px;
155 | font-size: 17px;
156 | font-weight: bold;
157 |
158 | &:hover {
159 | background-color: var(--twitter-dark-color);
160 | }
161 | `;
162 |
163 | const UserContainer = styled.div`
164 | display: flex;
165 | align-items: center;
166 | border-radius: 50px;
167 | padding: 8px 10px;
168 | cursor: pointer;
169 |
170 | &:hover {
171 | background-color: ${(props) => (props.current === "true" ? "#1e2125" : "#eeeeee")};
172 | }
173 | `;
174 |
175 | const UserContainerLink = styled(Link)`
176 | color: inherit;
177 | `;
178 |
179 | const UserPhoto = styled.img`
180 | flex: 1;
181 | width: 46px;
182 | height: 46px;
183 | border-radius: 50%;
184 | `;
185 |
186 | const UserInfo = styled.div`
187 | flex: 8;
188 | display: flex;
189 | flex-direction: column;
190 | justify-content: center;
191 | margin-left: 12px;
192 | `;
193 |
194 | const UserName = styled.div`
195 | font-weight: bold;
196 | font-size: 20px;
197 | margin-bottom: 5px;
198 | `;
199 |
200 | const UserEmail = styled.div`
201 | font-size: 17px;
202 | color: #989898;
203 | `;
204 |
205 | const IconUserEtcContainer = styled(FontAwesomeIcon)`
206 | flex: 1;
207 | font-size: 18px;
208 | cursor: pointer;
209 | padding-right: 10px;
210 | `;
211 |
212 | const CenterContainer = styled.div`
213 | width: 590px;
214 | max-width: 590px;
215 |
216 | @media (max-width: 768px) {
217 | width: 100%;
218 | }
219 | `;
220 |
221 | const ContentContainer = styled.div`
222 | border-bottom: 1px solid ${(props) => (props.current === "true" ? "#1e2125" : "#eee")};
223 | `;
224 |
225 | const ContentHeader = styled.div`
226 | display: flex;
227 | justify-content: space-between;
228 | align-items: center;
229 | padding: 10px 17px;
230 | border-bottom: 1px solid ${(props) => (props.current === "true" ? "#1e2125" : "#eee")};
231 | `;
232 |
233 | const ContentSearch = styled.div``;
234 |
235 | const ContentForm = styled.form`
236 | position: relative;
237 | `;
238 |
239 | const ContentInput = styled.input`
240 | border: none;
241 | outline: none;
242 | width: 310px;
243 | box-sizing: border-box;
244 | padding: 12px;
245 | padding-left: 50px;
246 | padding-right: 30px;
247 | border-radius: 30px;
248 | font-size: 15px;
249 | border: 1px solid transparent;
250 | color: #989898;
251 | background-color: ${(props) => (props.current === "true" ? "#1e2125" : "#f8f8f8")};
252 |
253 | &:focus {
254 | border: 1px solid #00aff0;
255 | background-color: ${(props) => (props.current === "true" ? "#1e2125" : "#f8f8f8")};
256 | }
257 | &::placeholder {
258 | color: #989898;
259 | }
260 |
261 | @media (max-width: 768px) {
262 | width: 230px;
263 | }
264 | `;
265 |
266 | const IconContentFormContainer = styled(FontAwesomeIcon)`
267 | font-size: 15px;
268 | cursor: pointer;
269 | color: gray;
270 | position: absolute;
271 | top: 50%;
272 | left: 22px;
273 | transform: translateY(-50%);
274 | `;
275 |
276 | const ContentTweet = styled.div`
277 | display: flex;
278 | padding: 17px 20px;
279 | border-bottom: 1px solid ${(props) => (props.current === "true" ? "#1e2125" : "#eee")};
280 | `;
281 |
282 | const TweetImage = styled.img`
283 | width: 47px;
284 | height: 47px;
285 | border-radius: 50%;
286 | `;
287 |
288 | const TweetPostContainer = styled.div`
289 | width: 100%;
290 | margin-left: 5px;
291 | `;
292 |
293 | const TweetPostHeader = styled.div`
294 | margin-left: 7px;
295 | `;
296 |
297 | const ContentArticle = styled.div``;
298 |
299 | const ContentPost = styled.div``;
300 |
301 | const ContentTweetNumber = styled.h1`
302 | font-size: 18px;
303 | font-weight: bold;
304 | margin-left: 5px;
305 | margin-top: 17px;
306 | margin-bottom: 15px;
307 | `;
308 |
309 | const ContentAllTweets = styled.div``;
310 |
311 | const RightContainer = styled.div`
312 | width: 330px;
313 | padding-left: 20px;
314 | position: fixed;
315 | height: 100vh;
316 | border-left: 1px solid ${(props) => (props.current === "true" ? "#1e2125" : "#eee")};
317 | `;
318 |
319 | const RegisterContainer = styled.div``;
320 |
321 | const TrendContainer = styled.div`
322 | background-color: ${(props) => (props.current === "true" ? "#1e2125" : "#f8f8f8")};
323 | border-radius: 20px;
324 | padding: 20px 0px;
325 | margin-top: 15px;
326 | `;
327 |
328 | const TrendHeader = styled.div`
329 | display: flex;
330 | align-items: center;
331 | justify-content: space-between;
332 | `;
333 |
334 | const TrendHeaderTitle = styled.h1`
335 | font-size: 20px;
336 | font-weight: bold;
337 | margin-left: 17px;
338 | `;
339 |
340 | const IconTrendContainer = styled(FontAwesomeIcon)`
341 | font-size: 16px;
342 | margin-right: 15px;
343 | cursor: pointer;
344 | `;
345 |
346 | const TrendInfo = styled.a`
347 | display: flex;
348 | justify-content: space-between;
349 | padding: 13px 17px;
350 | margin-top: 10px;
351 | cursor: pointer;
352 | color: ${(props) => (props.current === "true" ? "#cccccc" : "#31302E")};
353 |
354 | &:hover {
355 | background-color: ${(props) => (props.current === "true" ? "#2E3336" : "#eeeeee")};
356 | }
357 | `;
358 |
359 | const TrendContent = styled.div``;
360 |
361 | const TrendHeading = styled.h3`
362 | font-size: 13px;
363 | color: #989898;
364 | `;
365 |
366 | const TrendTitle = styled.h1`
367 | font-size: 16px;
368 | font-weight: bold;
369 | margin-top: 5px;
370 | `;
371 |
372 | const IconTrendDotContainer = styled(FontAwesomeIcon)`
373 | font-size: 15px;
374 | cursor: pointer;
375 | color: #989898;
376 | `;
377 |
378 | const SeeMore = styled.div`
379 | color: var(--twitter-color);
380 | font-size: 14px;
381 | cursor: pointer;
382 | margin-top: 10px;
383 | margin-left: 17px;
384 |
385 | &:hover {
386 | text-decoration: underline;
387 | }
388 | `;
389 |
390 | const FollowContainer = styled.div`
391 | background-color: ${(props) => (props.current === "true" ? "#1e2125" : "#f8f8f8")};
392 | border-radius: 20px;
393 | margin-top: 15px;
394 | padding: 20px 0px;
395 | `;
396 |
397 | const FollowHeader = styled.h1`
398 | font-size: 20px;
399 | font-weight: bold;
400 | margin-left: 17px;
401 | `;
402 |
403 | const FollowLink = styled.span`
404 | display: flex;
405 | align-items: center;
406 | width: 100%;
407 | color: inherits;
408 |
409 | &:visited {
410 | color: inherit;
411 | }
412 | `;
413 |
414 | const FollowContent = styled.div`
415 | display: flex;
416 | align-items: center;
417 | margin-top: 10px;
418 | cursor: pointer;
419 | padding: 10px 17px;
420 |
421 | &:hover {
422 | background-color: ${(props) => (props.current === "true" ? "#2E3336" : "#eeeeee")};
423 | }
424 | `;
425 |
426 | const FollowImage = styled.img`
427 | width: 47px;
428 | height: 47px;
429 | border-radius: 50%;
430 | `;
431 |
432 | const FollowInfo = styled.div`
433 | margin-left: 15px;
434 | margin-right: 20px;
435 | `;
436 |
437 | const FollowInfoTitle = styled.h1`
438 | font-weight: bold;
439 | font-size: 17px;
440 | margin-bottom: 5px;
441 | color: ${(props) => (props.current === "true" ? "#cccccc" : "#31302E")};
442 | `;
443 |
444 | const FollowInfoDesc = styled.h2`
445 | font-size: 15px;
446 | color: ${(props) => (props.current === "true" ? "#cccccc" : "#31302E")};
447 | `;
448 |
449 | const FollowButton = styled.span`
450 | color: white;
451 | padding: 7px 15px;
452 | border-radius: 50px;
453 | font-size: 14px;
454 | font-weight: bold;
455 | background-color: ${(props) => (props.current === "true" ? "#303030" : "#272c30")};
456 | margin-left: auto;
457 | `;
458 |
459 | const PolicyContainer = styled.div`
460 | margin-top: 20px;
461 | `;
462 |
463 | const PolicyHeader = styled.div``;
464 |
465 | const PolicyLink = styled.a`
466 | font-size: 11px;
467 | margin: 0 10px;
468 | color: gray;
469 |
470 | &:hover {
471 | text-decoration: underline;
472 | }
473 | `;
474 |
475 | const PolicyFooter = styled.div`
476 | font-size: 14px;
477 | margin-top: 10px;
478 | margin-left: 12px;
479 | `;
480 |
481 | const GototopButton = styled.button`
482 | position: fixed;
483 | bottom: 60px;
484 | right: 60px;
485 | z-index: 50;
486 | width: 47px;
487 | height: 47px;
488 | background: #1da1f2;
489 | border-radius: 50%;
490 | cursor: pointer;
491 | outline: none;
492 | border: none;
493 | box-shadow: rgba(0, 0, 0, 0.15) 0px 15px 25px, rgba(0, 0, 0, 0.05) 0px 5px 10px;
494 | transition: 0.3s;
495 |
496 | &:hover {
497 | transform: scale(0.85);
498 | }
499 |
500 | @media (max-width: 768px) {
501 | bottom: 70px;
502 | right: 20px;
503 | width: 40px;
504 | height: 40px;
505 | }
506 | `;
507 |
508 | const IconGototopButton = styled(FontAwesomeIcon)`
509 | font-size: 38px;
510 | color: white;
511 | `;
512 |
513 | // 모바일 하단 메뉴
514 | const MobileMenu = styled.div`
515 | display: none;
516 |
517 | @media (max-width: 768px) {
518 | display: flex;
519 | justify-content: space-around;
520 | align-items: center;
521 | position: fixed;
522 | bottom: 0;
523 | width: 100%;
524 | padding: 6px 0;
525 | box-sizing: border-box;
526 | border-top: 1px solid ${(props) => (props.current === "true" ? "#404040" : "#eee")};
527 | background-color: ${(props) => (props.current === "true" ? "#1e2125" : "#fff")};
528 | }
529 | `;
530 |
531 | const MobileHome = styled(Link)``;
532 |
533 | const MobileProfile = styled(Link)``;
534 |
535 | const MobileSearch = styled.div``;
536 |
537 | const MobileDarkMode = styled.button`
538 | font-size: 27px;
539 | `;
540 |
541 | const IconMobileHome = styled(FontAwesomeIcon)`
542 | font-size: 23px;
543 | color: #989898;
544 | `;
545 |
546 | const IconMobileProfile = styled(FontAwesomeIcon)`
547 | font-size: 23px;
548 | color: #989898;
549 | `;
550 |
551 | const IconMobileSearch = styled(FontAwesomeIcon)`
552 | font-size: 23px;
553 | color: #989898;
554 | `;
555 |
556 | // 팔로워 폼
557 | const LoginFormContainer = styled.div`
558 | position: fixed;
559 | top: 50%;
560 | left: 50%;
561 | transform: translate(-50%, -50%);
562 | width: 625px;
563 | height: 700px;
564 | overflow-y: scroll;
565 | background-color: white;
566 | border-radius: 20px;
567 | z-index: 100;
568 | box-shadow: rgba(0, 0, 0, 0.4) 0px 30px 90px;
569 | background-color: ${(props) => (props.current === "true" ? "#1e2125" : "#f8f8f8")};
570 | border: 1px solid ${(props) => (props.current === "true" ? "#404040" : "#eee")};
571 |
572 | &::-webkit-scrollbar {
573 | width: 11px;
574 | height: 11px;
575 | background: #ffffff;
576 | }
577 | &::-webkit-scrollbar-thumb {
578 | border-radius: 7px;
579 | background-color: #787878;
580 |
581 | &:hover {
582 | background-color: #444;
583 | }
584 | &:active {
585 | background-color: #444;
586 | }
587 | }
588 | &::-webkit-scrollbar-track {
589 | background-color: lightgray;
590 | }
591 | `;
592 |
593 | const MemberFormContainer = styled.div`
594 | position: fixed;
595 | top: 50%;
596 | left: 50%;
597 | transform: translate(-50%, -50%);
598 | width: 625px;
599 | height: 700px;
600 | overflow-y: scroll;
601 | background-color: white;
602 | border-radius: 20px;
603 | z-index: 50;
604 | box-shadow: rgba(0, 0, 0, 0.4) 0px 30px 90px;
605 | background-color: ${(props) => (props.current === "true" ? "#1e2125" : "#f8f8f8")};
606 | border: 1px solid ${(props) => (props.current === "true" ? "#404040" : "#eee")};
607 |
608 | &::-webkit-scrollbar {
609 | width: 11px;
610 | height: 11px;
611 | background: #ffffff;
612 | }
613 | &::-webkit-scrollbar-thumb {
614 | border-radius: 7px;
615 | background-color: #787878;
616 |
617 | &:hover {
618 | background-color: #444;
619 | }
620 | &:active {
621 | background-color: #444;
622 | }
623 | }
624 | &::-webkit-scrollbar-track {
625 | background-color: lightgray;
626 | }
627 | `;
628 |
629 | const LoginFormContent = styled.div`
630 | display: flex;
631 | flex-direction: column;
632 | justify-content: center;
633 | align-items: center;
634 | padding-top: 20px;
635 | padding-bottom: 20px;
636 | padding-left: 30px;
637 | padding-right: 35px;
638 | align-items: flex-start;
639 | `;
640 |
641 | const PostingTweetAuthorImage = styled.img`
642 | width: 47px;
643 | height: 47px;
644 | border-radius: 50%;
645 | margin-right: 17px;
646 | cursor: pointer;
647 |
648 | @media (max-width: 768px) {
649 | margin-right: 10px;
650 | }
651 | `;
652 |
653 | const PostingTweetContent = styled.div`
654 | width: 100%;
655 | `;
656 |
657 | const PostingTweetAuthor = styled.div`
658 | display: flex;
659 | align-items: center;
660 | justify-content: space-between;
661 |
662 | @media (max-width: 768px) {
663 | flex-direction: column;
664 | }
665 | `;
666 |
667 | const AuthorInfo = styled.div`
668 | display: flex;
669 | align-items: center;
670 | height: 40px;
671 |
672 | @media (max-width: 768px) {
673 | width: 100%;
674 | justify-content: flex-start;
675 | }
676 | `;
677 |
678 | const AuthorName = styled.h2`
679 | font-size: 17px;
680 | font-weight: bold;
681 |
682 | @media (max-width: 768px) {
683 | font-size: 15px;
684 | }
685 | `;
686 |
687 | const AuthorEmail = styled.h3`
688 | font-size: 16px;
689 | margin-left: 7px;
690 | color: gray;
691 | font-weight: 500;
692 |
693 | @media (max-width: 768px) {
694 | font-size: 15px;
695 | }
696 | `;
697 |
698 | const MemberInfo = styled.div`
699 | display: flex;
700 | flex-direction: column;
701 | justify-content: space-between;
702 | height: 40px;
703 | cursor: pointer;
704 |
705 | @media (max-width: 768px) {
706 | width: 100%;
707 | justify-content: flex-start;
708 | }
709 | `;
710 |
711 | const MemberName = styled.h2`
712 | font-size: 17px;
713 | font-weight: bold;
714 |
715 | @media (max-width: 768px) {
716 | font-size: 15px;
717 | }
718 | `;
719 |
720 | const MemberEmail = styled.h3`
721 | font-size: 16px;
722 | color: gray;
723 | font-weight: 500;
724 |
725 | @media (max-width: 768px) {
726 | font-size: 15px;
727 | }
728 | `;
729 |
730 | const AuthorCreatedAt = styled.h4`
731 | font-size: 14px;
732 | color: gray;
733 | font-weight: 500;
734 |
735 | @media (max-width: 768px) {
736 | font-size: 13px;
737 | }
738 | `;
739 |
740 | const AuthorDot = styled.span`
741 | font-size: 15px;
742 | margin: 0 5px;
743 |
744 | @media (max-width: 768px) {
745 | font-size: 12px;
746 | }
747 | `;
748 |
749 | const PostingTweetDesc = styled.p`
750 | margin-bottom: 8px;
751 | font-size: 16px;
752 | line-height: 1.5;
753 | `;
754 |
755 | const PostingTweetImage = styled.img`
756 | width: 490px;
757 | height: 280px;
758 | border-radius: 15px;
759 |
760 | @media (max-width: 768px) {
761 | width: 100%;
762 | height: 200px;
763 | }
764 | `;
765 |
766 | const IconTweetLikeNumber = styled.span`
767 | color: #f91880;
768 | font-size: 15px;
769 | font-weight: 500;
770 | margin-left: 5px;
771 | `;
772 |
773 | const IconSVGContainer = styled.div`
774 | display: flex;
775 | align-items: center;
776 | justify-content: space-between;
777 | margin-top: 7px;
778 | `;
779 |
780 | const IconHeartContainer = styled.div`
781 | display: flex;
782 | align-items: center;
783 | width: 50px;
784 | `;
785 |
786 | const CloseButton = styled(FontAwesomeIcon)`
787 | position: absolute;
788 | top: 30px;
789 | right: 38px;
790 | font-size: 28px;
791 | cursor: pointer;
792 | color: gray;
793 |
794 | &:hover {
795 | color: ${(props) => (props.current === "true" ? "#DCDCDC" : "#303030")};
796 | }
797 | `;
798 |
799 | const PostingTweetFollowerContainer = styled.div`
800 | margin-top: 18px;
801 | `;
802 |
803 | const PostingTweetTitle = styled.h1`
804 | font-size: 20px;
805 | font-weight: bold;
806 | margin-bottom: 32px;
807 | `;
808 |
809 | const PostingTweetFollower = styled.div`
810 | display: flex;
811 | margin-bottom: 30px;
812 | `;
813 |
814 | const IconSVG = styled.svg`
815 | height: 20px;
816 | cursor: pointer;
817 | border-radius: 50%;
818 | padding: 5px;
819 |
820 | &:hover {
821 | fill: ${(props) => (props.current === "true" ? "#1DA1F2" : "#bebebe")};
822 | background-color: ${(props) => (props.current === "true" ? "#2E3336" : "#e6f3ff")};
823 | }
824 | `;
825 |
826 | const IconG = styled.g``;
827 |
828 | const IconPath = styled.path``;
829 |
830 | const Home = ({ userObject, refreshDisplayName, createNotification, isDark, changeTheme }) => {
831 | // const [isDesc, setIsDesc] = useState(true); // 트윗 정렬 순서
832 | const [allTweets, setAllTweets] = useState(""); // Document에 있는 모튼 트윗들
833 | const [allTweetsLength, setAllTweetsLength] = useState(0); // Document에 있는 모튼 트윗 갯수
834 | const [searchText, setSearchText] = useState(""); // 트위터 검색
835 | const [searchTweetLength, setSearchTweetsLength] = useState(0);
836 | const [searchTweet, setSearchTweets] = useState("");
837 | const [isFollower, setIsFollower] = useState(false);
838 | const [isMember, setIsMember] = useState(false);
839 | const [isSearchTweetAuthor, setSearchTweetAuthor] = useState("유저");
840 | const [allMembers, setAllMembers] = useState("");
841 | const twitterSearch = useRef();
842 | const history = useHistory();
843 | const {
844 | location: { pathname },
845 | } = history;
846 |
847 | /*
848 | // 트윗 정렬 (최신순, 오래된순)
849 | const handleOrderBy = async () => {
850 | await firestoreService
851 | .collection("tweets")
852 | .orderBy("createdAtTime", `${isDesc ? "asc" : "desc"}`)
853 | .onSnapshot((querySnapshot) => {
854 | const querySnapshotSize = querySnapshot.size;
855 | const queryDocumentSnapshotObjectArray = querySnapshot.docs.map((queryDocumentSnapshot) => ({
856 | documentId: queryDocumentSnapshot.id,
857 | ...queryDocumentSnapshot.data(),
858 | }));
859 |
860 | setAllTweetsLength(querySnapshotSize);
861 | setAllTweets(queryDocumentSnapshotObjectArray);
862 | });
863 | setIsDesc(!isDesc);
864 | };
865 | */
866 |
867 | // 공유하기 버튼
868 | const shareTwitter = () => {
869 | var sendText = "노마드 코더 트위터 클론";
870 | var sendUrl = "https://nomadcoders.co/nwitter";
871 | window.open(`https://twitter.com/intent/tweet?text=${sendText}&url=${sendUrl}`);
872 | };
873 |
874 | // 트위터 검색 결과창
875 | const onSearchSubmit = (event) => {
876 | event.preventDefault();
877 | window.open(`https://twitter.com/search?q=${searchText}&src=typed_query`);
878 | setSearchText("");
879 | };
880 |
881 | // 트위터 검색창 value
882 | const onSearchInput = (event) => {
883 | const {
884 | target: { value },
885 | } = event;
886 |
887 | setSearchText(value);
888 | };
889 |
890 | // 트위터 검색창 포커싱
891 | const onFocusTwitterSearch = (event) => {
892 | twitterSearch.current.focus();
893 | };
894 |
895 | // const handleOrderByDesc = async () => {
896 | // const getDesc = await firestoreService.collection("tweets").orderBy("createdAtTime", "desc").get();
897 | // console.log("getDesc", getDesc);
898 | // };
899 |
900 | const handleFollower = async (email) => {
901 | await firestoreService
902 | .collection("tweets")
903 | .where("email", "==", email)
904 | .orderBy("createdAtTime", "desc")
905 | .onSnapshot((querySnapshot) => {
906 | const querySnapshotSize = querySnapshot.size;
907 | const queryDocumentSnapshotObjectArray = querySnapshot.docs.map((queryDocumentSnapshot) => ({
908 | id: queryDocumentSnapshot.id,
909 | documentId: queryDocumentSnapshot.id,
910 | ...queryDocumentSnapshot.data(),
911 | }));
912 | setSearchTweetsLength(querySnapshotSize);
913 | setSearchTweets(queryDocumentSnapshotObjectArray);
914 | setSearchTweetAuthor(queryDocumentSnapshotObjectArray[0]?.displayName);
915 | });
916 |
917 | setIsFollower(!isFollower);
918 | };
919 |
920 | const handleCloseFollower = () => {
921 | setIsFollower(false);
922 | };
923 |
924 | const handleMember = async (email) => {
925 | const querySnapshot = await firestoreService.collection("tweets").get();
926 | const allMembersArray = querySnapshot.docs.map((queryDocumentSnapshot) => ({
927 | id: queryDocumentSnapshot.id,
928 | displayName: queryDocumentSnapshot.data().displayName,
929 | email: queryDocumentSnapshot.data().email,
930 | photoURL: queryDocumentSnapshot.data().photoURL,
931 | }));
932 | const filterAllMembersArray = _.uniq(allMembersArray, "email");
933 |
934 | setAllMembers(filterAllMembersArray);
935 | setIsMember(true);
936 | };
937 |
938 | const handleCloseMember = () => {
939 | setIsMember(false);
940 | };
941 |
942 | const getTime = (time) => {
943 | const now = parseInt(time);
944 | const date = new Date(now);
945 | const day = ["일", "월", "화", "수", "목", "금", "토"];
946 | const getMonth = date.getMonth() + 1;
947 | const getDate = date.getDate();
948 | const getDay = day[date.getDay()];
949 | return `${getMonth}월 ${getDate}일 (${getDay})`;
950 | };
951 |
952 | useEffect(() => {
953 | firestoreService
954 | .collection("tweets")
955 | .orderBy("createdAtTime", "desc")
956 | .onSnapshot((querySnapshot) => {
957 | // 전체 트윗 가져오기 (map사용)
958 | const queryDocumentSnapshotObjectArray = querySnapshot.docs.map((queryDocumentSnapshot) => ({
959 | id: queryDocumentSnapshot.id,
960 | documentId: queryDocumentSnapshot.id,
961 | ...queryDocumentSnapshot.data(),
962 | }));
963 | const querySnapshotSize = querySnapshot.size;
964 |
965 | setAllTweetsLength(querySnapshotSize);
966 | setAllTweets(queryDocumentSnapshotObjectArray);
967 | });
968 | }, []);
969 |
970 | return (
971 | <>
972 |
973 |
974 | {`트위터 / 홈`}
975 |
976 | {/* 메뉴 (좌측) */}
977 |
978 |
979 |
980 |
981 |
982 |
983 |
984 |
985 |
986 |
987 |
988 | 홈
989 |
990 |
991 |
992 | 프로필
993 |
994 |
995 |
996 | 검색
997 |
998 |
999 |
1000 | 팔로우
1001 |
1002 |
1003 |
1004 | 쪽지
1005 |
1006 |
1007 |
1008 | 북마크
1009 |
1010 |
1011 |
1012 | 리스트
1013 |
1014 |
1015 |
1016 | 더보기
1017 |
1018 |
1019 |
1020 | 공유하기
1021 |
1022 |
1023 |
1024 |
1025 |
1026 |
1027 | {userObject?.displayName ? userObject.displayName : "유저"}
1028 | {userObject?.email ? userObject.email : "로그인 안됨"}
1029 |
1030 |
1031 |
1032 |
1033 |
1034 |
1035 |
1036 | {/* 트윗 목록 (중앙) */}
1037 |
1038 |
1039 |
1040 |
1041 | {/* {isDesc ? "오래된순" : "최신순"} */}
1042 | 전체 트윗 ({allTweetsLength})
1043 |
1044 |
1045 |
1053 |
1054 |
1055 |
1056 |
1057 |
1058 |
1059 |
1060 |
1061 |
1062 |
1063 |
1064 |
1065 |
1066 |
1067 | {pathname === "/" ? (
1068 |
1069 | {allTweets &&
1070 | allTweets.map((tweetObject) => {
1071 | return (
1072 |
1080 | );
1081 | })}
1082 |
1083 | ) : (
1084 |
1085 |
1092 |
1093 | )}
1094 |
1095 |
1096 |
1097 |
1098 |
1099 |
1100 | {/* 트렌드, 팔로우 (우측) */}
1101 |
1102 |
1103 |
1104 |
1105 |
1106 |
1107 |
1108 | 나를 위한 트렌드
1109 |
1110 |
1111 |
1112 |
1113 | 노마드코더에서 트렌드 중
1114 | 트위터 클론
1115 |
1116 |
1117 |
1118 |
1119 |
1120 | 페이스북, 구글에서 트렌드 중
1121 | 리액트, 파이어베이스
1122 |
1123 |
1124 |
1125 | 더 보기
1126 |
1127 |
1128 | 팔로우 추천
1129 | handleFollower("nomadcoders@twitter.com")}>
1130 |
1131 |
1132 |
1133 | Nomad Coders
1134 | @Nomad Coders
1135 |
1136 | 팔로우
1137 |
1138 |
1139 | handleFollower("apple@twitter.com")}>
1140 |
1141 |
1142 |
1143 | Apple
1144 | @Apple
1145 |
1146 | 팔로우
1147 |
1148 |
1149 | handleFollower("nasa@twitter.com")}>
1150 |
1151 |
1152 |
1153 | NASA
1154 | @NASA
1155 |
1156 | 팔로우
1157 |
1158 |
1159 | handleFollower("coding@twitter.com")}>
1160 |
1161 |
1162 |
1163 | Coding
1164 | @Coding
1165 |
1166 | 팔로우
1167 |
1168 |
1169 | 더 보기
1170 |
1171 |
1172 |
1173 |
1174 | 이용약관
1175 |
1176 |
1177 | 개인정보 처리방침
1178 |
1179 |
1180 | 쿠키 정책
1181 |
1182 |
1183 | 광고 정보
1184 |
1185 |
1186 | © 2021 GW. ALL RIGHTS RESERVED.
1187 |
1188 |
1189 |
1190 |
1191 | window.scrollTo(0, 0)}>
1192 |
1193 |
1194 |
1195 | {/* 팔로워 폼 */}
1196 | {isFollower ? (
1197 |
1198 |
1199 |
1200 |
1201 |
1202 | {isSearchTweetAuthor && isSearchTweetAuthor}님이 작성한 트윗 ({searchTweetLength})
1203 |
1204 | {searchTweet &&
1205 | searchTweet.map((tweetObject, index) => (
1206 |
1207 |
1208 |
1209 |
1210 |
1211 | {tweetObject.displayName}
1212 | {tweetObject.email}
1213 | ·
1214 | {getTime(tweetObject.createdAtTime)}
1215 |
1216 |
1217 | {tweetObject.content}
1218 | {tweetObject.fileDownloadUrl && }
1219 |
1220 |
1221 |
1222 |
1229 |
1230 |
1231 |
1232 |
1233 | {tweetObject.likesArray.length}
1234 |
1235 |
1242 |
1243 |
1244 |
1245 |
1246 |
1253 |
1254 |
1255 |
1256 |
1257 |
1264 |
1265 |
1266 |
1267 |
1268 |
1269 |
1270 |
1271 |
1272 | ))}
1273 |
1274 |
1275 |
1276 | ) : null}
1277 |
1278 | {/* 멤버 폼 */}
1279 | {isMember ? (
1280 |
1281 |
1282 |
1283 |
1284 | 팔로우 추천 멤버
1285 | {allMembers &&
1286 | allMembers.map((tweetObject, index) => (
1287 |
1288 |
1289 |
1290 |
1291 | handleFollower(tweetObject.email)}>
1292 | {tweetObject.displayName}
1293 | {tweetObject.email}
1294 |
1295 |
1296 | {tweetObject.content}
1297 | {tweetObject.fileDownloadUrl && }
1298 |
1299 |
1300 | ))}
1301 |
1302 |
1303 |
1304 | ) : null}
1305 |
1306 |
1307 |
1308 |
1309 |
1310 |
1311 |
1312 |
1313 |
1314 |
1315 |
1316 |
1317 | {isDark ? "🌙" : "🌞"}
1318 |
1319 |
1320 |
1321 | >
1322 | );
1323 | };
1324 |
1325 | Home.propTypes = {
1326 | userObject: PropTypes.object,
1327 | refreshDisplayName: PropTypes.func.isRequired,
1328 | createNotification: PropTypes.func.isRequired,
1329 | isDark: PropTypes.bool.isRequired,
1330 | changeTheme: PropTypes.func.isRequired,
1331 | };
1332 |
1333 | export default Home;
1334 |
--------------------------------------------------------------------------------
/src/routes/Profile.js:
--------------------------------------------------------------------------------
1 | import { useRef, useEffect, useState } from "react";
2 | import { authService, firestoreService, storageService } from "firebaseConfiguration";
3 | import styled from "styled-components";
4 | import PropTypes from "prop-types";
5 | import { Helmet } from "react-helmet";
6 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
7 | import { faCalendarAlt } from "@fortawesome/free-regular-svg-icons";
8 | import { faCheckCircle, faSignOutAlt, faCamera, faUserEdit } from "@fortawesome/free-solid-svg-icons";
9 | import userImage from "images/user.png";
10 |
11 | const ProfileContainer = styled.div``;
12 |
13 | const ProfileEdit = styled.div``;
14 |
15 | const ProfileForm = styled.form`
16 | display: flex;
17 | flex-direction: column;
18 | `;
19 |
20 | const ProfileImageContainer = styled.div`
21 | display: flex;
22 | align-items: flex-end;
23 | justify-content: space-between;
24 | padding: 0 17px;
25 | margin-top: 40px;
26 | `;
27 |
28 | const ProfileFormImage = styled.img`
29 | border-radius: 50%;
30 | width: 120px;
31 | height: 120px;
32 | border: 5px solid #d0d0d0;
33 | cursor: pointer;
34 | `;
35 |
36 | const ProfileButtons = styled.div`
37 | display: flex;
38 | align-items: center;
39 | `;
40 |
41 | const ProfileFormSubmit = styled.input`
42 | border: none;
43 | outline: none;
44 | cursor: pointer;
45 | padding: 10px 15px;
46 | color: white;
47 | border-radius: 30px;
48 | font-size: 15px;
49 | font-weight: bold;
50 | background-color: var(--twitter-color);
51 |
52 | &:hover {
53 | background-color: var(--twitter-dark-color);
54 | }
55 | `;
56 |
57 | const TweetFormDisplayNameLabel = styled.label``;
58 |
59 | const IconUserEdit = styled(FontAwesomeIcon)`
60 | font-size: 24px;
61 | cursor: pointer;
62 | color: #bebebe;
63 | border-radius: 50%;
64 | margin-right: 10px;
65 |
66 | &:hover {
67 | color: var(--twitter-color);
68 | }
69 | `;
70 |
71 | const ProfileFormDisplayName = styled.input`
72 | border: none;
73 | outline: none;
74 | font-size: 20px;
75 | font-weight: bold;
76 | margin-top: 16px;
77 | margin-bottom: 6px;
78 | margin-left: 14px;
79 | color: ${(props) => (props.current === "true" ? "#989898" : "black")};
80 | background-color: transparent;
81 |
82 | &::placeholder {
83 | font-size: 17px;
84 | }
85 | `;
86 |
87 | const ProfileFormFile = styled.input``;
88 |
89 | const TweetFormImageLabel = styled.label`
90 | position: relative;
91 | `;
92 |
93 | const IconCamera = styled(FontAwesomeIcon)`
94 | font-size: 25px;
95 | cursor: pointer;
96 | padding: 7px;
97 | border-radius: 50%;
98 | position: absolute;
99 | top: 50%;
100 | left: 50%;
101 | transform: translate(-50%, -50%);
102 | color: ${(props) => (props.current === "true" ? "white" : "#bebebe")};
103 |
104 | &:hover {
105 | color: var(--twitter-color);
106 | background-color: ${(props) => (props.current === "true" ? "white" : "#e6f3ff")};
107 | }
108 | `;
109 |
110 | const ProfileInfo = styled.div`
111 | padding: 0 17px;
112 | `;
113 |
114 | const ProfileDisplayName = styled.div``;
115 |
116 | const ProfileEmailContainer = styled.div`
117 | display: flex;
118 | `;
119 |
120 | const ProfileEmail = styled.h1`
121 | font-size: 17px;
122 | margin-right: 5px;
123 | color: gray;
124 | `;
125 |
126 | const ProfileEmailVerified = styled(FontAwesomeIcon)`
127 | color: var(--twitter-color);
128 | `;
129 |
130 | const ProfileCreation = styled.div`
131 | margin-top: 15px;
132 | `;
133 |
134 | const ProfileLastSignIn = styled.div`
135 | margin-top: 4px;
136 | `;
137 |
138 | const IconProfileCreation = styled(FontAwesomeIcon)`
139 | color: gray;
140 | margin-right: 5px;
141 | `;
142 |
143 | const IconProfileSignIn = styled(FontAwesomeIcon)`
144 | color: gray;
145 | margin-right: 3px;
146 | `;
147 |
148 | const ProfileTweet = styled.div`
149 | margin-top: 20px;
150 | `;
151 |
152 | // 작성한 트윗 목록
153 | const PostingMyTweetContainer = styled.div`
154 | margin-top: 50px;
155 | border-top: 1px solid ${(props) => (props.current === "true" ? "#1e2125" : "#eee")};
156 |
157 | @media (max-width: 768px) {
158 | margin-top: 20px;
159 | }
160 | `;
161 |
162 | const PostingMyTweetTitle = styled.h1`
163 | font-size: 18px;
164 | font-weight: bold;
165 | margin-left: 17px;
166 | margin-top: 25px;
167 | margin-bottom: 20px;
168 | `;
169 |
170 | const PostingMyTweet = styled.div`
171 | display: flex;
172 | padding: 10px 17px;
173 | cursor: pointer;
174 | border-bottom: 1px solid ${(props) => (props.current === "true" ? "#1e2125" : "#eee")};
175 | background-color: ${(props) => (props.current === "true" ? "#0F0F0F" : "#ffffff")};
176 |
177 | &:hover {
178 | background-color: ${(props) => (props.current === "true" ? "#1e2125" : "#f8f8f8")};
179 | }
180 | &:last-child {
181 | border-bottom: none;
182 | }
183 | `;
184 |
185 | const PostingTweetAuthorImage = styled.img`
186 | width: 47px;
187 | height: 47px;
188 | border-radius: 50%;
189 | margin-right: 17px;
190 | `;
191 |
192 | const PostingTweetContent = styled.div`
193 | width: 100%;
194 | `;
195 |
196 | const PostingTweetAuthor = styled.div`
197 | display: flex;
198 | align-items: center;
199 | justify-content: space-between;
200 | `;
201 |
202 | const AuthorInfo = styled.div`
203 | display: flex;
204 | align-items: center;
205 | height: 40px;
206 | `;
207 |
208 | const AuthorName = styled.h2`
209 | font-size: 17px;
210 | font-weight: bold;
211 |
212 | @media (max-width: 768px) {
213 | font-size: 15px;
214 | }
215 | `;
216 |
217 | const AuthorEmail = styled.h3`
218 | font-size: 16px;
219 | margin-left: 7px;
220 | color: gray;
221 | font-weight: 500;
222 |
223 | @media (max-width: 768px) {
224 | font-size: 15px;
225 | }
226 | `;
227 |
228 | const AuthorCreatedAt = styled.h4`
229 | font-size: 14px;
230 | color: gray;
231 | font-weight: 500;
232 |
233 | @media (max-width: 768px) {
234 | font-size: 13px;
235 | }
236 | `;
237 |
238 | const AuthorDot = styled.span`
239 | font-size: 15px;
240 | margin: 0 5px;
241 |
242 | @media (max-width: 768px) {
243 | font-size: 12px;
244 | }
245 | `;
246 |
247 | const PostingTweetDesc = styled.p`
248 | margin-bottom: 8px;
249 | font-size: 16px;
250 | line-height: 1.5;
251 | `;
252 |
253 | const PostingTweetImage = styled.img`
254 | width: 490px;
255 | height: 280px;
256 | border-radius: 15px;
257 |
258 | @media (max-width: 768px) {
259 | width: 100%;
260 | height: 200px;
261 | }
262 | `;
263 |
264 | const Profile = ({ userObject, refreshDisplayName, createNotification, isDark }) => {
265 | const creationTime = userObject?.creationTime;
266 | const lastSignInTime = userObject?.lastSignInTime;
267 | const [newDisplayName, setNewDisplayName] = useState(authService.currentUser?.displayName);
268 | const [myTweets, setMyTweets] = useState([]);
269 | const [fileDataUrl, setFileDataUrl] = useState("");
270 | const [fileName, setFileName] = useState("");
271 | const fileImageInput = useRef();
272 | let fileDownloadUrl = "";
273 |
274 | const getTime = (time) => {
275 | const now = parseInt(time);
276 | const date = new Date(now);
277 | const getFullYear = date.getFullYear();
278 | const getMonth = date.getMonth() + 1;
279 | const getDate = date.getDate();
280 | return `${getFullYear}년 ${getMonth}월 ${getDate}일`;
281 | };
282 |
283 | const getMyTweets = async () => {
284 | const tweets = await firestoreService.collection("tweets").where("uid", "==", userObject.uid).orderBy("createdAtTime", "desc").get();
285 | // Document에서 특정한 필드의 데이터만 가져오기
286 | // tweets.docs.map((doc)=>doc.get("content"))
287 |
288 | // Document에서 모든 필드의 데이터 가져오기
289 | // tweets.docs.map((doc)=>doc.data())
290 |
291 | const myTweetsArray = tweets.docs.map((doc) => ({
292 | ...doc.data(),
293 | }));
294 | setMyTweets(myTweetsArray);
295 | };
296 |
297 | // 프로필 수정 (프로필 업데이트)
298 | const onSubmit = async (event) => {
299 | // authService.currentUser: 현재 로그인한 사용자 정보
300 | event.preventDefault();
301 |
302 | if (fileDataUrl !== "") {
303 | const fileReference = storageService.ref().child(`${userObject.email}/profile/${fileName}`);
304 | const uploadTask = await fileReference.putString(fileDataUrl, "data_url");
305 | fileDownloadUrl = await uploadTask.ref.getDownloadURL();
306 | }
307 |
308 | // firebase.User = userObject: 현재 로그인한 사용자 정보
309 | await userObject.updateProfile({
310 | displayName: newDisplayName,
311 | photoURL: fileDownloadUrl,
312 | });
313 |
314 | refreshDisplayName();
315 | createNotification("SuccessProfile");
316 | };
317 |
318 | const onChange = (event) => {
319 | const {
320 | target: { value },
321 | } = event;
322 | setNewDisplayName(value);
323 | };
324 |
325 | const onFileChange = (event) => {
326 | const {
327 | target: { files },
328 | } = event;
329 | const uploadFile = files[0];
330 | const uploadFileName = uploadFile?.name;
331 | const fileReader = new FileReader();
332 |
333 | if (fileReader && uploadFile !== undefined && uploadFile !== null) {
334 | fileReader.onload = (event) => {
335 | const {
336 | target: { result },
337 | } = event;
338 | setFileDataUrl(result);
339 | fileImageInput.current.src = result;
340 | };
341 | fileReader.readAsDataURL(uploadFile);
342 | }
343 | setFileName(`${uploadFileName}_${Date.now()}`);
344 | };
345 |
346 | const onClickPostingImage = (event) => {
347 | const {
348 | target: { src },
349 | } = event;
350 |
351 | if (src) {
352 | window.open(src);
353 | }
354 | };
355 |
356 | useEffect(() => {
357 | getMyTweets();
358 | }, []);
359 |
360 | return (
361 | <>
362 |
363 | {`트위터 / ${userObject?.email && userObject.email} 프로필`}
364 |
365 |
366 |
367 |
368 |
369 |
370 |
371 |
372 |
373 |
374 |
375 |
376 |
377 |
378 |
379 |
380 |
391 |
392 |
393 |
394 |
395 |
396 | {userObject?.email}
397 | {!userObject?.emailVerified === true && }
398 |
399 |
400 |
401 | 계정 생성일: {getTime(creationTime)}
402 |
403 |
404 |
405 | 마지막 로그인: {getTime(lastSignInTime)}
406 |
407 |
408 |
409 |
410 | {myTweets && myTweets.length > 0 ? (
411 |
412 | 작성한 트윗 ({myTweets.length})
413 | {myTweets.map((myTweet, index) => {
414 | return (
415 |
416 |
417 |
418 |
419 |
420 | {myTweet.displayName}
421 | {myTweet.email}
422 | ·
423 | {getTime(myTweet.createdAtTime).slice(6, 12)}
424 |
425 |
426 | {myTweet.content}
427 | {myTweet.fileDownloadUrl && }
428 |
429 |
430 | );
431 | })}
432 |
433 | ) : null}
434 |
435 |
436 | >
437 | );
438 | };
439 |
440 | Profile.propTypes = {
441 | userObject: PropTypes.object,
442 | refreshDisplayName: PropTypes.func.isRequired,
443 | createNotification: PropTypes.func.isRequired,
444 | isDark: PropTypes.bool.isRequired,
445 | };
446 |
447 | export default Profile;
448 |
--------------------------------------------------------------------------------
/src/theme/GlobalStyle.js:
--------------------------------------------------------------------------------
1 | import { createGlobalStyle } from "styled-components";
2 | import reset from "styled-reset";
3 |
4 | const GlobalStyle = createGlobalStyle`
5 | ${reset}
6 |
7 | :root{
8 | --twitter-color: #1DA1F2;
9 | --twitter-dark-color: #00AFF0;
10 | }
11 | html{
12 | scroll-behavior: smooth;
13 | }
14 | body{
15 | font-family: -apple-system,
16 | BlinkMacSystemFont,
17 | "Segoe UI",
18 | Roboto,
19 | Oxygen-Sans,
20 | Ubuntu,
21 | Cantarell,
22 | "Helvetica Neue",
23 | sans-serif;
24 | background-color: ${(props) => (props.bgColor === true ? "#101010" : "#ffffff")};
25 | color: ${(props) => (props.color === true ? "#cccccc" : "#31302E")};
26 | border-color: ${(props) => (props.borderColor === true ? "#2c2d33" : "#eaeaea")};
27 | display:flex;
28 | justify-content:center;
29 | position:relative;
30 |
31 | &::-webkit-scrollbar {
32 | width: 11px;
33 | height: 11px;
34 | background: #ffffff;
35 | }
36 | &::-webkit-scrollbar-thumb {
37 | border-radius: 7px;
38 | background-color: #787878;
39 |
40 | &:hover {
41 | background-color: #C0C0C0;
42 | }
43 | &:active{
44 | background-color: #C0C0C0;
45 | }
46 | }
47 | &::-webkit-scrollbar-track {
48 | background-color: #404040;
49 | }
50 | }
51 | #root.active::before{
52 | content:"";
53 | position:absolute;
54 | top:0;
55 | left:0;
56 | width:100%;
57 | height:100vh;
58 | background-color:rgba(0,0,0,0.6);
59 | z-index: 5;
60 | }
61 | button{
62 | border:none;
63 | outline:none;
64 | cursor: pointer;
65 | background-color:transparent;
66 | padding:0;
67 | }
68 | a{
69 | text-decoration:none;
70 | }
71 | a:focus, a:visited{
72 | /* color:inherit; */
73 | }
74 | aside.emoji-picker-react{
75 | position:absolute;
76 | top:40px;
77 | }
78 |
79 | @media (max-width:768px){
80 | #root{
81 | width:100%;
82 | }
83 | }
84 | `;
85 |
86 | export default GlobalStyle;
87 |
--------------------------------------------------------------------------------