├── .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 |
2 | 3 | 4 |
5 |
http://githubgw.github.io/twitter-clone 6 |

7 | 8 | 9 |
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 | 668 | ) : ( 669 | 681 | )} 682 | {tweetObject.likesArray.length} 683 | 684 | 695 | 706 | 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 | 761 | {tweetObject.likesArray.length} 762 | 763 | 774 | 785 | 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 | 261 | 262 | 263 | 278 | {isEmoji ? : null} 279 | 293 | 303 | 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 | 1233 | {tweetObject.likesArray.length} 1234 | 1235 | 1246 | 1257 | 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 | --------------------------------------------------------------------------------