├── .eslintrc.json ├── .gitignore ├── LICENSE.txt ├── README.md ├── instagram-preview.png ├── package.json ├── postcss.config.js ├── public ├── favicon.ico ├── images │ ├── avatars │ │ ├── dali.jpg │ │ ├── default.png │ │ ├── karl.jpg │ │ ├── orwell.jpg │ │ ├── raphael.jpg │ │ └── steve.jpg │ ├── iphone-with-profile.jpg │ ├── logo.png │ └── users │ │ ├── logo.png │ │ └── raphael │ │ ├── 1.jpg │ │ ├── 2.jpg │ │ ├── 3.jpg │ │ ├── 4.jpg │ │ └── 5.jpg └── index.html ├── src ├── App.js ├── components │ ├── header.js │ ├── loader.js │ ├── post │ │ ├── actions.js │ │ ├── add-comment.js │ │ ├── comments.js │ │ ├── footer.js │ │ ├── header.js │ │ ├── image.js │ │ └── index.js │ ├── profile │ │ ├── header.js │ │ ├── index.js │ │ └── photos.js │ ├── sidebar │ │ ├── index.js │ │ ├── suggested-profile.js │ │ ├── suggestions.js │ │ └── user.js │ └── timeline.js ├── constants │ ├── paths.js │ └── routes.js ├── context │ ├── firebase.js │ ├── logged-in-user.js │ └── user.js ├── helpers │ └── protected-route.js ├── hooks │ ├── use-auth-listener.js │ ├── use-photos.js │ └── use-user.js ├── index.js ├── lib │ └── firebase.js ├── pages │ ├── dashboard.js │ ├── login.js │ ├── not-found.js │ ├── profile.js │ └── sign-up.js ├── seed.js ├── services │ └── firebase.js └── styles │ ├── app.css │ └── tailwind.css ├── tailwind.config.js └── yarn.lock /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "airbnb", 4 | "prettier", 5 | "prettier/react", 6 | "plugin:jsx-a11y/recommended" 7 | ], 8 | "parser": "babel-eslint", 9 | "parserOptions": { 10 | "ecmaVersion": 8, 11 | "ecmaFeatures": { 12 | "experimentalObjectRestSpread": true, 13 | "impliedStrict": true, 14 | "classes": true 15 | } 16 | }, 17 | "env": { 18 | "browser": true, 19 | "node": true, 20 | "jquery": true, 21 | "jest": true 22 | }, 23 | "rules": { 24 | "react-hooks/rules-of-hooks": "error", 25 | "no-debugger": 0, 26 | "no-alert": 0, 27 | "no-unused-vars": 1, 28 | "prefer-const": [ 29 | "error", 30 | { 31 | "destructuring": "all" 32 | } 33 | ], 34 | "arrow-body-style": [ 35 | 2, 36 | "as-needed" 37 | ], 38 | "no-unused-expressions": [ 39 | 2, 40 | { 41 | "allowTaggedTemplates": true 42 | } 43 | ], 44 | "no-param-reassign": [ 45 | 2, 46 | { 47 | "props": false 48 | } 49 | ], 50 | "no-console": 0, 51 | "import/prefer-default-export": 0, 52 | "import": 0, 53 | "func-names": 0, 54 | "space-before-function-paren": 0, 55 | "comma-dangle": 0, 56 | "max-len": 0, 57 | "import/extensions": 0, 58 | "no-underscore-dangle": 0, 59 | "consistent-return": 0, 60 | "react/display-name": 1, 61 | "react/no-array-index-key": 0, 62 | "react/react-in-jsx-scope": 0, 63 | "react/prefer-stateless-function": 0, 64 | "react/forbid-prop-types": 0, 65 | "react/jsx-props-no-spreading": 0, 66 | "react/no-unescaped-entities": 0, 67 | "jsx-a11y/accessible-emoji": 0, 68 | "react/require-default-props": 0, 69 | "react/jsx-filename-extension": [ 70 | 1, 71 | { 72 | "extensions": [ 73 | ".js", 74 | ".jsx" 75 | ] 76 | } 77 | ], 78 | "radix": 0, 79 | "no-shadow": "off", 80 | "quotes": [ 81 | 2, 82 | "single", 83 | { 84 | "avoidEscape": true, 85 | "allowTemplateLiterals": true 86 | } 87 | ], 88 | "prettier/prettier": [ 89 | "error", 90 | { 91 | "trailingComma": "none", 92 | "singleQuote": true, 93 | "printWidth": 100 94 | } 95 | ], 96 | "jsx-a11y/href-no-hash": "off", 97 | "jsx-a11y/anchor-is-valid": [ 98 | "warn", 99 | { 100 | "aspects": [ 101 | "invalidHref" 102 | ] 103 | } 104 | ] 105 | }, 106 | "plugins": [ 107 | "prettier", 108 | "react", 109 | "react-hooks" 110 | ] 111 | } 112 | -------------------------------------------------------------------------------- /.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 | .eslintcache 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Karl Hadwen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Building Instagram from Scratch Using React, Tailwind CSS, Firebase (11+ Hour Tutorial Here: https://youtu.be/AKeaaa8yAAk) 2 | 3 | 💰 Extended paid version here (3 hours 30 mins extra): https://gum.co/react-instagram-clone 4 | 5 | ## 📣 Summary 6 | 7 | This application (Instagram clone) was built using React (Custom Hooks, Context), Firebase & Tailwind CSS. I have built the following pages within this application: login, sign up, dashboard & lastly the user profile page. There are four different pages, some are public and some are private with auth listeners. Firebase firestore handles all the data, and that data is retrieved using a custom hook. 8 | 9 | I used Tailwind CSS for this project and I really enjoyed using it. I used styled components in my previous project, but I have now converted all my projects to Tailwind CSS for ease of use. This will be my last project using Firebase as it's far too complex to use and far too complex to test (especially with Cypress). With Jest, it's also tedious to test Firebase as there's no great mocking library, so you end up just repeating yourself in the tests a lot. 10 | 11 | ## 💷 Extended Videos - Tailwind CSS Responsive, React Testing Library & Cypress Tests 12 | 13 | If you're interested in the paid version of this course which includes making this application responsive and testing via React Testing Library and Cypress, you can find that here: https://gum.co/react-instagram-clone - a purchase shows your appreciation and allows me to spend more time making videos 🙌 14 | 15 | ## 🎥 Subscribe 16 | 17 | Subscribe to my YouTube channel here: http://bit.ly/CognitiveSurge where I build projects like this! And don't forget, you can contribute to this project (highly encouraged!). 18 | 19 | ![Preview](instagram-preview.png?raw=true) 20 | -------------------------------------------------------------------------------- /instagram-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karlhadwen/instagram/6657346a485cb5d46c5cdc5c082f2c7d592a65ea/instagram-preview.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "instagram", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^5.11.4", 7 | "@testing-library/react": "^11.1.0", 8 | "@testing-library/user-event": "^12.1.10", 9 | "@welldone-software/why-did-you-render": "^6.0.5", 10 | "date-fns": "^2.16.1", 11 | "firebase": "^8.2.5", 12 | "prop-types": "^15.7.2", 13 | "react": "^17.0.1", 14 | "react-dom": "^17.0.1", 15 | "react-loader-spinner": "^4.0.0", 16 | "react-loading-skeleton": "^2.2.0", 17 | "react-router-dom": "^5.2.0", 18 | "react-scripts": "4.0.1", 19 | "web-vitals": "^0.2.4" 20 | }, 21 | "devDependencies": { 22 | "autoprefixer": "^10.2.4", 23 | "babel-eslint": "^10.1.0", 24 | "eslint": "^7.19.0", 25 | "eslint-config-airbnb": "^18.2.1", 26 | "eslint-config-prettier": "^7.2.0", 27 | "eslint-plugin-import": "^2.22.1", 28 | "eslint-plugin-jsx-a11y": "^6.4.1", 29 | "eslint-plugin-prettier": "^3.3.1", 30 | "eslint-plugin-react": "^7.22.0", 31 | "eslint-plugin-react-hooks": "^4.2.0", 32 | "npm-run-all": "^4.1.5", 33 | "postcss": "^8.2.4", 34 | "postcss-cli": "^8.3.1", 35 | "prettier": "^2.2.1", 36 | "tailwindcss": "^2.0.2" 37 | }, 38 | "scripts": { 39 | "build:css": "postcss src/styles/tailwind.css -o src/styles/app.css", 40 | "watch:css": "postcss src/styles/tailwind.css -o src/styles/app.css --watch", 41 | "react-scripts:start": "sleep 5 && react-scripts start", 42 | "react-scripts:dist": "react-scripts build", 43 | "start": "run-p watch:css react-scripts:start", 44 | "build": "run-s build:css react-scripts:dist", 45 | "test": "react-scripts test", 46 | "eject": "react-scripts eject" 47 | }, 48 | "eslintConfig": { 49 | "extends": [ 50 | "react-app", 51 | "react-app/jest" 52 | ] 53 | }, 54 | "browserslist": { 55 | "production": [ 56 | ">0.2%", 57 | "not dead", 58 | "not op_mini all" 59 | ], 60 | "development": [ 61 | "last 1 chrome version", 62 | "last 1 firefox version", 63 | "last 1 safari version" 64 | ] 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [require('tailwindcss'), require('autoprefixer')] 3 | }; 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karlhadwen/instagram/6657346a485cb5d46c5cdc5c082f2c7d592a65ea/public/favicon.ico -------------------------------------------------------------------------------- /public/images/avatars/dali.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karlhadwen/instagram/6657346a485cb5d46c5cdc5c082f2c7d592a65ea/public/images/avatars/dali.jpg -------------------------------------------------------------------------------- /public/images/avatars/default.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karlhadwen/instagram/6657346a485cb5d46c5cdc5c082f2c7d592a65ea/public/images/avatars/default.png -------------------------------------------------------------------------------- /public/images/avatars/karl.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karlhadwen/instagram/6657346a485cb5d46c5cdc5c082f2c7d592a65ea/public/images/avatars/karl.jpg -------------------------------------------------------------------------------- /public/images/avatars/orwell.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karlhadwen/instagram/6657346a485cb5d46c5cdc5c082f2c7d592a65ea/public/images/avatars/orwell.jpg -------------------------------------------------------------------------------- /public/images/avatars/raphael.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karlhadwen/instagram/6657346a485cb5d46c5cdc5c082f2c7d592a65ea/public/images/avatars/raphael.jpg -------------------------------------------------------------------------------- /public/images/avatars/steve.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karlhadwen/instagram/6657346a485cb5d46c5cdc5c082f2c7d592a65ea/public/images/avatars/steve.jpg -------------------------------------------------------------------------------- /public/images/iphone-with-profile.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karlhadwen/instagram/6657346a485cb5d46c5cdc5c082f2c7d592a65ea/public/images/iphone-with-profile.jpg -------------------------------------------------------------------------------- /public/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karlhadwen/instagram/6657346a485cb5d46c5cdc5c082f2c7d592a65ea/public/images/logo.png -------------------------------------------------------------------------------- /public/images/users/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karlhadwen/instagram/6657346a485cb5d46c5cdc5c082f2c7d592a65ea/public/images/users/logo.png -------------------------------------------------------------------------------- /public/images/users/raphael/1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karlhadwen/instagram/6657346a485cb5d46c5cdc5c082f2c7d592a65ea/public/images/users/raphael/1.jpg -------------------------------------------------------------------------------- /public/images/users/raphael/2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karlhadwen/instagram/6657346a485cb5d46c5cdc5c082f2c7d592a65ea/public/images/users/raphael/2.jpg -------------------------------------------------------------------------------- /public/images/users/raphael/3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karlhadwen/instagram/6657346a485cb5d46c5cdc5c082f2c7d592a65ea/public/images/users/raphael/3.jpg -------------------------------------------------------------------------------- /public/images/users/raphael/4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karlhadwen/instagram/6657346a485cb5d46c5cdc5c082f2c7d592a65ea/public/images/users/raphael/4.jpg -------------------------------------------------------------------------------- /public/images/users/raphael/5.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karlhadwen/instagram/6657346a485cb5d46c5cdc5c082f2c7d592a65ea/public/images/users/raphael/5.jpg -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Instagram 9 | 14 | 15 | 16 | 17 |
18 | 19 | 20 | -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import { lazy, Suspense } from 'react'; 2 | import { BrowserRouter as Router, Route, Switch } from 'react-router-dom'; 3 | import ReactLoader from './components/loader'; 4 | import * as ROUTES from './constants/routes'; 5 | import UserContext from './context/user'; 6 | import useAuthListener from './hooks/use-auth-listener'; 7 | 8 | import ProtectedRoute from './helpers/protected-route'; 9 | 10 | const Login = lazy(() => import('./pages/login')); 11 | const SignUp = lazy(() => import('./pages/sign-up')); 12 | const Dashboard = lazy(() => import('./pages/dashboard')); 13 | const Profile = lazy(() => import('./pages/profile')); 14 | const NotFound = lazy(() => import('./pages/not-found')); 15 | 16 | export default function App() { 17 | const { user } = useAuthListener(); 18 | 19 | return ( 20 | 21 | 22 | }> 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /src/components/header.js: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | import { Link, useHistory } from 'react-router-dom'; 3 | import FirebaseContext from '../context/firebase'; 4 | import UserContext from '../context/user'; 5 | import * as ROUTES from '../constants/routes'; 6 | import { DEFAULT_IMAGE_PATH } from '../constants/paths'; 7 | import useUser from '../hooks/use-user'; 8 | 9 | export default function Header() { 10 | const { user: loggedInUser } = useContext(UserContext); 11 | const { user } = useUser(loggedInUser?.uid); 12 | const { firebase } = useContext(FirebaseContext); 13 | const history = useHistory(); 14 | 15 | return ( 16 |
17 |
18 |
19 |
20 |

21 | 22 | Instagram 23 | 24 |

25 |
26 |
27 | {loggedInUser ? ( 28 | <> 29 | 30 | 37 | 43 | 44 | 45 | 46 | 75 | {user && ( 76 |
77 | 78 | {`${user?.username} { 83 | e.target.src = DEFAULT_IMAGE_PATH; 84 | }} 85 | /> 86 | 87 |
88 | )} 89 | 90 | ) : ( 91 | <> 92 | 93 | 99 | 100 | 101 | 107 | 108 | 109 | )} 110 |
111 |
112 |
113 |
114 | ); 115 | } 116 | -------------------------------------------------------------------------------- /src/components/loader.js: -------------------------------------------------------------------------------- 1 | import Loader from 'react-loader-spinner'; 2 | 3 | export default function ReactLoader() { 4 | return ( 5 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /src/components/post/actions.js: -------------------------------------------------------------------------------- 1 | import { useState, useContext } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import FirebaseContext from '../../context/firebase'; 4 | import UserContext from '../../context/user'; 5 | 6 | export default function Actions({ docId, totalLikes, likedPhoto, handleFocus }) { 7 | const { 8 | user: { uid: userId } 9 | } = useContext(UserContext); 10 | const [toggleLiked, setToggleLiked] = useState(likedPhoto); 11 | const [likes, setLikes] = useState(totalLikes); 12 | const { firebase, FieldValue } = useContext(FirebaseContext); 13 | 14 | const handleToggleLiked = async () => { 15 | setToggleLiked((toggleLiked) => !toggleLiked); 16 | 17 | await firebase 18 | .firestore() 19 | .collection('photos') 20 | .doc(docId) 21 | .update({ 22 | likes: toggleLiked ? FieldValue.arrayRemove(userId) : FieldValue.arrayUnion(userId) 23 | }); 24 | 25 | setLikes((likes) => (toggleLiked ? likes - 1 : likes + 1)); 26 | }; 27 | 28 | return ( 29 | <> 30 |
31 |
32 | { 35 | if (event.key === 'Enter') { 36 | handleToggleLiked(); 37 | } 38 | }} 39 | xmlns="http://www.w3.org/2000/svg" 40 | fill="none" 41 | viewBox="0 0 24 24" 42 | stroke="currentColor" 43 | tabIndex={0} 44 | className={`w-8 mr-4 select-none cursor-pointer focus:outline-none ${ 45 | toggleLiked ? 'fill-red text-red-primary' : 'text-black-light' 46 | }`} 47 | > 48 | 54 | 55 | { 58 | if (event.key === 'Enter') { 59 | handleFocus(); 60 | } 61 | }} 62 | className="w-8 text-black-light select-none cursor-pointer focus:outline-none" 63 | xmlns="http://www.w3.org/2000/svg" 64 | fill="none" 65 | viewBox="0 0 24 24" 66 | stroke="currentColor" 67 | tabIndex={0} 68 | > 69 | 75 | 76 |
77 |
78 |
79 |

{likes === 1 ? `${likes} like` : `${likes} likes`}

80 |
81 | 82 | ); 83 | } 84 | 85 | Actions.propTypes = { 86 | docId: PropTypes.string.isRequired, 87 | totalLikes: PropTypes.number.isRequired, 88 | likedPhoto: PropTypes.bool.isRequired, 89 | handleFocus: PropTypes.func.isRequired 90 | }; 91 | -------------------------------------------------------------------------------- /src/components/post/add-comment.js: -------------------------------------------------------------------------------- 1 | import { useState, useContext } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import FirebaseContext from '../../context/firebase'; 4 | import UserContext from '../../context/user'; 5 | 6 | export default function AddComment({ docId, comments, setComments, commentInput }) { 7 | const [comment, setComment] = useState(''); 8 | const { firebase, FieldValue } = useContext(FirebaseContext); 9 | const { 10 | user: { displayName } 11 | } = useContext(UserContext); 12 | 13 | const handleSubmitComment = (event) => { 14 | event.preventDefault(); 15 | 16 | setComments([...comments, { displayName, comment }]); 17 | setComment(''); 18 | 19 | return firebase 20 | .firestore() 21 | .collection('photos') 22 | .doc(docId) 23 | .update({ 24 | comments: FieldValue.arrayUnion({ displayName, comment }) 25 | }); 26 | }; 27 | 28 | return ( 29 |
30 |
34 | comment.length >= 1 ? handleSubmitComment(event) : event.preventDefault() 35 | } 36 | > 37 | setComment(target.value)} 46 | ref={commentInput} 47 | /> 48 | 56 |
57 |
58 | ); 59 | } 60 | 61 | AddComment.propTypes = { 62 | docId: PropTypes.string.isRequired, 63 | comments: PropTypes.array.isRequired, 64 | setComments: PropTypes.func.isRequired, 65 | commentInput: PropTypes.object 66 | }; 67 | -------------------------------------------------------------------------------- /src/components/post/comments.js: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { formatDistance } from 'date-fns'; 4 | import { Link } from 'react-router-dom'; 5 | import AddComment from './add-comment'; 6 | 7 | export default function Comments({ docId, comments: allComments, posted, commentInput }) { 8 | const [comments, setComments] = useState(allComments); 9 | const [commentsSlice, setCommentsSlice] = useState(3); 10 | 11 | const showNextComments = () => { 12 | setCommentsSlice(commentsSlice + 3); 13 | }; 14 | 15 | return ( 16 | <> 17 |
18 | {comments.slice(0, commentsSlice).map((item) => ( 19 |

20 | 21 | {item.displayName} 22 | 23 | {item.comment} 24 |

25 | ))} 26 | {comments.length >= 3 && commentsSlice < comments.length && ( 27 | 39 | )} 40 |

41 | {formatDistance(posted, new Date())} ago 42 |

43 |
44 | 50 | 51 | ); 52 | } 53 | 54 | Comments.propTypes = { 55 | docId: PropTypes.string.isRequired, 56 | comments: PropTypes.array.isRequired, 57 | posted: PropTypes.number.isRequired, 58 | commentInput: PropTypes.object.isRequired 59 | }; 60 | -------------------------------------------------------------------------------- /src/components/post/footer.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | 3 | export default function Footer({ caption, username }) { 4 | return ( 5 |
6 | {username} 7 | {caption} 8 |
9 | ); 10 | } 11 | 12 | Footer.propTypes = { 13 | caption: PropTypes.string.isRequired, 14 | username: PropTypes.string.isRequired 15 | }; 16 | -------------------------------------------------------------------------------- /src/components/post/header.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable jsx-a11y/img-redundant-alt */ 2 | import PropTypes from 'prop-types'; 3 | import { Link } from 'react-router-dom'; 4 | 5 | export default function Header({ username }) { 6 | return ( 7 |
8 |
9 | 10 | {`${username} 15 |

{username}

16 | 17 |
18 |
19 | ); 20 | } 21 | 22 | Header.propTypes = { 23 | username: PropTypes.string.isRequired 24 | }; 25 | -------------------------------------------------------------------------------- /src/components/post/image.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | 3 | export default function Image({ src, caption }) { 4 | return {caption}; 5 | } 6 | 7 | Image.propTypes = { 8 | src: PropTypes.string.isRequired, 9 | caption: PropTypes.string.isRequired 10 | }; 11 | -------------------------------------------------------------------------------- /src/components/post/index.js: -------------------------------------------------------------------------------- 1 | import { useRef } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import Header from './header'; 4 | import Image from './image'; 5 | import Actions from './actions'; 6 | import Footer from './footer'; 7 | import Comments from './comments'; 8 | 9 | export default function Post({ content }) { 10 | const commentInput = useRef(null); 11 | const handleFocus = () => commentInput.current.focus(); 12 | 13 | // components 14 | // -> header, image, actions (like & comment icons), footer, comments 15 | return ( 16 |
17 |
18 | 19 | 25 |
26 | 32 |
33 | ); 34 | } 35 | 36 | Post.propTypes = { 37 | content: PropTypes.shape({ 38 | username: PropTypes.string.isRequired, 39 | imageSrc: PropTypes.string.isRequired, 40 | caption: PropTypes.string.isRequired, 41 | docId: PropTypes.string.isRequired, 42 | userLikedPhoto: PropTypes.bool.isRequired, 43 | likes: PropTypes.array.isRequired, 44 | comments: PropTypes.array.isRequired, 45 | dateCreated: PropTypes.number.isRequired 46 | }) 47 | }; 48 | -------------------------------------------------------------------------------- /src/components/profile/header.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable jsx-a11y/img-redundant-alt */ 2 | import { useState, useEffect, useContext } from 'react'; 3 | import PropTypes from 'prop-types'; 4 | import Skeleton from 'react-loading-skeleton'; 5 | import useUser from '../../hooks/use-user'; 6 | import { isUserFollowingProfile, toggleFollow } from '../../services/firebase'; 7 | import UserContext from '../../context/user'; 8 | import { DEFAULT_IMAGE_PATH } from '../../constants/paths'; 9 | 10 | export default function Header({ 11 | photosCount, 12 | followerCount, 13 | setFollowerCount, 14 | profile: { 15 | docId: profileDocId, 16 | userId: profileUserId, 17 | fullName, 18 | followers, 19 | following, 20 | username: profileUsername 21 | } 22 | }) { 23 | const { user: loggedInUser } = useContext(UserContext); 24 | const { user } = useUser(loggedInUser?.uid); 25 | const [isFollowingProfile, setIsFollowingProfile] = useState(null); 26 | const activeBtnFollow = user?.username && user?.username !== profileUsername; 27 | 28 | const handleToggleFollow = async () => { 29 | setIsFollowingProfile((isFollowingProfile) => !isFollowingProfile); 30 | setFollowerCount({ 31 | followerCount: isFollowingProfile ? followerCount - 1 : followerCount + 1 32 | }); 33 | await toggleFollow(isFollowingProfile, user.docId, profileDocId, profileUserId, user.userId); 34 | }; 35 | 36 | useEffect(() => { 37 | const isLoggedInUserFollowingProfile = async () => { 38 | const isFollowing = await isUserFollowingProfile(user.username, profileUserId); 39 | setIsFollowingProfile(!!isFollowing); 40 | }; 41 | 42 | if (user?.username && profileUserId) { 43 | isLoggedInUserFollowingProfile(); 44 | } 45 | }, [user?.username, profileUserId]); 46 | 47 | return ( 48 |
49 |
50 | {profileUsername ? ( 51 | {`${fullName} { 56 | e.target.src = DEFAULT_IMAGE_PATH; 57 | }} 58 | /> 59 | ) : ( 60 | 61 | )} 62 |
63 |
64 |
65 |

{profileUsername}

66 | {activeBtnFollow && isFollowingProfile === null ? ( 67 | 68 | ) : ( 69 | activeBtnFollow && ( 70 | 82 | ) 83 | )} 84 |
85 |
86 | {!followers || !following ? ( 87 | 88 | ) : ( 89 | <> 90 |

91 | {photosCount} photos 92 |

93 |

94 | {followerCount} 95 | {` `} 96 | {followerCount === 1 ? `follower` : `followers`} 97 |

98 |

99 | {following?.length} following 100 |

101 | 102 | )} 103 |
104 |
105 |

{!fullName ? : fullName}

106 |
107 |
108 |
109 | ); 110 | } 111 | 112 | Header.propTypes = { 113 | photosCount: PropTypes.number.isRequired, 114 | followerCount: PropTypes.number.isRequired, 115 | setFollowerCount: PropTypes.func.isRequired, 116 | profile: PropTypes.shape({ 117 | docId: PropTypes.string, 118 | userId: PropTypes.string, 119 | fullName: PropTypes.string, 120 | username: PropTypes.string, 121 | followers: PropTypes.array, 122 | following: PropTypes.array 123 | }).isRequired 124 | }; 125 | -------------------------------------------------------------------------------- /src/components/profile/index.js: -------------------------------------------------------------------------------- 1 | import { useReducer, useEffect } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import Header from './header'; 4 | import Photos from './photos'; 5 | import { getUserPhotosByUserId } from '../../services/firebase'; 6 | 7 | export default function Profile({ user }) { 8 | const reducer = (state, newState) => ({ ...state, ...newState }); 9 | const initialState = { 10 | profile: {}, 11 | photosCollection: null, 12 | followerCount: 0 13 | }; 14 | 15 | const [{ profile, photosCollection, followerCount }, dispatch] = useReducer( 16 | reducer, 17 | initialState 18 | ); 19 | 20 | useEffect(() => { 21 | async function getProfileInfoAndPhotos() { 22 | const photos = await getUserPhotosByUserId(user.userId); 23 | dispatch({ profile: user, photosCollection: photos, followerCount: user.followers.length }); 24 | } 25 | getProfileInfoAndPhotos(); 26 | }, [user.username]); 27 | 28 | return ( 29 | <> 30 |
36 | 37 | 38 | ); 39 | } 40 | 41 | Profile.propTypes = { 42 | user: PropTypes.shape({ 43 | dateCreated: PropTypes.number, 44 | emailAddress: PropTypes.string, 45 | followers: PropTypes.array, 46 | following: PropTypes.array, 47 | fullName: PropTypes.string, 48 | userId: PropTypes.string, 49 | username: PropTypes.string 50 | }) 51 | }; 52 | -------------------------------------------------------------------------------- /src/components/profile/photos.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-nested-ternary */ 2 | import PropTypes from 'prop-types'; 3 | import Skeleton from 'react-loading-skeleton'; 4 | 5 | export default function Photos({ photos }) { 6 | return ( 7 |
8 |
9 | {!photos 10 | ? new Array(12).fill(0).map((_, i) => ) 11 | : photos.length > 0 12 | ? photos.map((photo) => ( 13 |
14 | {photo.caption} 15 | 16 |
17 |

18 | 24 | 29 | 30 | {photo.likes.length} 31 |

32 | 33 |

34 | 40 | 45 | 46 | {photo.comments.length} 47 |

48 |
49 |
50 | )) 51 | : null} 52 |
53 | 54 | {!photos || (photos.length === 0 &&

No Posts Yet

)} 55 |
56 | ); 57 | } 58 | 59 | Photos.propTypes = { 60 | photos: PropTypes.array 61 | }; 62 | -------------------------------------------------------------------------------- /src/components/sidebar/index.js: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | import User from './user'; 3 | import Suggestions from './suggestions'; 4 | import LoggedInUserContext from '../../context/logged-in-user'; 5 | 6 | export default function Sidebar() { 7 | const { user: { docId = '', fullName, username, userId, following } = {} } = useContext( 8 | LoggedInUserContext 9 | ); 10 | 11 | return ( 12 |
13 | 14 | 15 |
16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /src/components/sidebar/suggested-profile.js: -------------------------------------------------------------------------------- 1 | import { useState, useContext } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Link } from 'react-router-dom'; 4 | import { 5 | updateLoggedInUserFollowing, 6 | updateFollowedUserFollowers, 7 | getUserByUserId 8 | } from '../../services/firebase'; 9 | import LoggedInUserContext from '../../context/logged-in-user'; 10 | 11 | export default function SuggestedProfile({ 12 | profileDocId, 13 | username, 14 | profileId, 15 | userId, 16 | loggedInUserDocId 17 | }) { 18 | const [followed, setFollowed] = useState(false); 19 | const { setActiveUser } = useContext(LoggedInUserContext); 20 | 21 | async function handleFollowUser() { 22 | setFollowed(true); 23 | await updateLoggedInUserFollowing(loggedInUserDocId, profileId, false); 24 | await updateFollowedUserFollowers(profileDocId, userId, false); 25 | const [user] = await getUserByUserId(userId); 26 | setActiveUser(user); 27 | } 28 | 29 | return !followed ? ( 30 |
31 |
32 | { 37 | e.target.src = `/images/avatars/default.png`; 38 | }} 39 | /> 40 | 41 |

{username}

42 | 43 |
44 | 51 |
52 | ) : null; 53 | } 54 | 55 | SuggestedProfile.propTypes = { 56 | profileDocId: PropTypes.string.isRequired, 57 | username: PropTypes.string.isRequired, 58 | profileId: PropTypes.string.isRequired, 59 | userId: PropTypes.string.isRequired, 60 | loggedInUserDocId: PropTypes.string.isRequired 61 | }; 62 | -------------------------------------------------------------------------------- /src/components/sidebar/suggestions.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-nested-ternary */ 2 | import { useState, useEffect } from 'react'; 3 | import PropTypes from 'prop-types'; 4 | import Skeleton from 'react-loading-skeleton'; 5 | import { getSuggestedProfiles } from '../../services/firebase'; 6 | import SuggestedProfile from './suggested-profile'; 7 | 8 | export default function Suggestions({ userId, following, loggedInUserDocId }) { 9 | const [profiles, setProfiles] = useState(null); 10 | 11 | useEffect(() => { 12 | async function suggestedProfiles() { 13 | const response = await getSuggestedProfiles(userId, following); 14 | setProfiles(response); 15 | } 16 | 17 | if (userId) { 18 | suggestedProfiles(); 19 | } 20 | }, [userId]); 21 | // hint: use the firebase service (call using userId) 22 | // getSuggestedProfiles 23 | // call the async function ^^^^ within useEffect 24 | // store it in state 25 | // go ahead and render (wait on the profiles as in 'skeleton') 26 | 27 | return !profiles ? ( 28 | 29 | ) : profiles.length > 0 ? ( 30 |
31 |
32 |

Suggestions for you

33 |
34 |
35 | {profiles.map((profile) => ( 36 | 44 | ))} 45 |
46 |
47 | ) : null; 48 | } 49 | 50 | Suggestions.propTypes = { 51 | userId: PropTypes.string, 52 | following: PropTypes.array, 53 | loggedInUserDocId: PropTypes.string 54 | }; 55 | -------------------------------------------------------------------------------- /src/components/sidebar/user.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import { Link } from 'react-router-dom'; 3 | import Skeleton from 'react-loading-skeleton'; 4 | import { DEFAULT_IMAGE_PATH } from '../../constants/paths'; 5 | 6 | export default function User({ username, fullName }) { 7 | return !username || !fullName ? ( 8 | 9 | ) : ( 10 | 11 |
12 | { 17 | e.target.src = DEFAULT_IMAGE_PATH; 18 | }} 19 | /> 20 |
21 |
22 |

{username}

23 |

{fullName}

24 |
25 | 26 | ); 27 | } 28 | 29 | User.propTypes = { 30 | username: PropTypes.string, 31 | fullName: PropTypes.string 32 | }; 33 | -------------------------------------------------------------------------------- /src/components/timeline.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-nested-ternary */ 2 | import { useContext } from 'react'; 3 | import Skeleton from 'react-loading-skeleton'; 4 | import LoggedInUserContext from '../context/logged-in-user'; 5 | import usePhotos from '../hooks/use-photos'; 6 | import Post from './post'; 7 | 8 | export default function Timeline() { 9 | 10 | const { user } = useContext(LoggedInUserContext); 11 | 12 | const { user: { following } = {} } = useContext( 13 | LoggedInUserContext 14 | ); 15 | 16 | const { photos } = usePhotos(user); 17 | 18 | 19 | return ( 20 |
21 | {following===undefined ?( 22 | 23 | ) : following.length===0 ?( 24 |

Follow other people to see Photos

25 | ) : photos? ( 26 | photos.map((content) => ) 27 | ) : null} 28 | 29 | 30 |
31 | ); 32 | } 33 | 34 | -------------------------------------------------------------------------------- /src/constants/paths.js: -------------------------------------------------------------------------------- 1 | export const DEFAULT_IMAGE_PATH = '/images/avatars/default.png'; 2 | -------------------------------------------------------------------------------- /src/constants/routes.js: -------------------------------------------------------------------------------- 1 | export const DASHBOARD = '/'; 2 | export const LOGIN = '/login'; 3 | export const SIGN_UP = '/sign-up'; 4 | export const PROFILE = '/p/:username'; 5 | export const NOT_FOUND = '/not-found'; 6 | -------------------------------------------------------------------------------- /src/context/firebase.js: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | 3 | const FirebaseContext = createContext(null); 4 | export default FirebaseContext; 5 | -------------------------------------------------------------------------------- /src/context/logged-in-user.js: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | 3 | const LoggedInUserContext = createContext(null); 4 | export default LoggedInUserContext; 5 | -------------------------------------------------------------------------------- /src/context/user.js: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | 3 | const UserContext = createContext(null); 4 | export default UserContext; 5 | -------------------------------------------------------------------------------- /src/helpers/protected-route.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Route, Redirect } from 'react-router-dom'; 4 | import * as ROUTES from '../constants/routes'; 5 | 6 | export default function ProtectedRoute({ user, children, ...rest }) { 7 | return ( 8 | { 11 | if (user) { 12 | return React.cloneElement(children, { user }); 13 | } 14 | 15 | if (!user) { 16 | return ( 17 | 23 | ); 24 | } 25 | 26 | return null; 27 | }} 28 | /> 29 | ); 30 | } 31 | 32 | ProtectedRoute.propTypes = { 33 | user: PropTypes.object, 34 | children: PropTypes.object.isRequired 35 | }; 36 | -------------------------------------------------------------------------------- /src/hooks/use-auth-listener.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useContext } from 'react'; 2 | import FirebaseContext from '../context/firebase'; 3 | 4 | export default function useAuthListener() { 5 | const [user, setUser] = useState(JSON.parse(localStorage.getItem('authUser'))); 6 | const { firebase } = useContext(FirebaseContext); 7 | 8 | useEffect(() => { 9 | const listener = firebase.auth().onAuthStateChanged((authUser) => { 10 | if (authUser) { 11 | // we have a user...therefore we can store the user in localstorage 12 | localStorage.setItem('authUser', JSON.stringify(authUser)); 13 | setUser(authUser); 14 | } else { 15 | // we don't have an authUser, therefore clear the localstorage 16 | localStorage.removeItem('authUser'); 17 | setUser(null); 18 | } 19 | }); 20 | 21 | return () => listener(); 22 | }, [firebase]); 23 | 24 | return { user }; 25 | } 26 | -------------------------------------------------------------------------------- /src/hooks/use-photos.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import { getPhotos } from '../services/firebase'; 3 | 4 | export default function usePhotos(user) { 5 | const [photos, setPhotos] = useState(null); 6 | 7 | useEffect(() => { 8 | async function getTimelinePhotos() { 9 | // does the user actually follow people? 10 | if (user?.following?.length > 0) { 11 | const followedUserPhotos = await getPhotos(user.userId, user.following); 12 | // re-arrange array to be newest photos first by dateCreated 13 | followedUserPhotos.sort((a, b) => b.dateCreated - a.dateCreated); 14 | setPhotos(followedUserPhotos); 15 | } 16 | } 17 | 18 | getTimelinePhotos(); 19 | }, [user?.userId, user?.following]); 20 | 21 | return { photos }; 22 | } 23 | -------------------------------------------------------------------------------- /src/hooks/use-user.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import { getUserByUserId } from '../services/firebase'; 3 | 4 | export default function useUser(userId) { 5 | const [activeUser, setActiveUser] = useState(); 6 | 7 | useEffect(() => { 8 | async function getUserObjByUserId(userId) { 9 | const [user] = await getUserByUserId(userId); 10 | setActiveUser(user || {}); 11 | } 12 | 13 | if (userId) { 14 | getUserObjByUserId(userId); 15 | } 16 | }, [userId]); 17 | 18 | return { user: activeUser, setActiveUser }; 19 | } 20 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | import FirebaseContext from './context/firebase'; 5 | import { firebase, FieldValue } from './lib/firebase'; 6 | import './styles/app.css'; 7 | 8 | ReactDOM.render( 9 | 10 | 11 | , 12 | document.getElementById('root') 13 | ); 14 | -------------------------------------------------------------------------------- /src/lib/firebase.js: -------------------------------------------------------------------------------- 1 | import Firebase from 'firebase/app'; 2 | import 'firebase/firestore'; 3 | import 'firebase/auth'; 4 | 5 | const config = { 6 | apiKey: '', 7 | authDomain: '', 8 | projectId: '', 9 | storageBucket: '', 10 | messagingSenderId: '', 11 | appId: '' 12 | }; 13 | 14 | const firebase = Firebase.initializeApp(config); 15 | const { FieldValue } = Firebase.firestore; 16 | 17 | export { firebase, FieldValue }; 18 | -------------------------------------------------------------------------------- /src/pages/dashboard.js: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import Header from '../components/header'; 4 | import Timeline from '../components/timeline'; 5 | import Sidebar from '../components/sidebar'; 6 | import useUser from '../hooks/use-user'; 7 | import LoggedInUserContext from '../context/logged-in-user'; 8 | 9 | export default function Dashboard({ user: loggedInUser }) { 10 | const { user, setActiveUser } = useUser(loggedInUser.uid); 11 | useEffect(() => { 12 | document.title = 'Instagram'; 13 | }, []); 14 | 15 | return ( 16 | 17 |
18 |
19 |
20 | 21 | 22 |
23 |
24 |
25 | ); 26 | } 27 | 28 | Dashboard.propTypes = { 29 | user: PropTypes.object.isRequired 30 | }; 31 | -------------------------------------------------------------------------------- /src/pages/login.js: -------------------------------------------------------------------------------- 1 | import { useState, useContext, useEffect } from 'react'; 2 | import { Link, useHistory } from 'react-router-dom'; 3 | import FirebaseContext from '../context/firebase'; 4 | import * as ROUTES from '../constants/routes'; 5 | 6 | export default function Login() { 7 | const history = useHistory(); 8 | const { firebase } = useContext(FirebaseContext); 9 | 10 | const [emailAddress, setEmailAddress] = useState(''); 11 | const [password, setPassword] = useState(''); 12 | 13 | const [error, setError] = useState(''); 14 | const isInvalid = password === '' || emailAddress === ''; 15 | 16 | const handleLogin = async (event) => { 17 | event.preventDefault(); 18 | 19 | try { 20 | await firebase.auth().signInWithEmailAndPassword(emailAddress, password); 21 | history.push(ROUTES.DASHBOARD); 22 | } catch (error) { 23 | setEmailAddress(''); 24 | setPassword(''); 25 | setError(error.message); 26 | } 27 | }; 28 | 29 | useEffect(() => { 30 | document.title = 'Login - Instagram'; 31 | }, []); 32 | 33 | return ( 34 |
35 |
36 | iPhone with Instagram app 37 |
38 |
39 |
40 |

41 | Instagram 42 |

43 | 44 | {error &&

{error}

} 45 | 46 |
47 | setEmailAddress(target.value)} 53 | value={emailAddress} 54 | /> 55 | setPassword(target.value)} 61 | value={password} 62 | /> 63 | 71 |
72 |
73 |
74 |

75 | Don't have an account?{` `} 76 | 77 | Sign up 78 | 79 |

80 |
81 |
82 |
83 | ); 84 | } 85 | -------------------------------------------------------------------------------- /src/pages/not-found.js: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import Header from '../components/header'; 3 | 4 | export default function NotFound() { 5 | useEffect(() => { 6 | document.title = 'Not Found - Instagram'; 7 | }, []); 8 | 9 | return ( 10 |
11 |
12 |
13 |

Not Found!

14 |
15 |
16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /src/pages/profile.js: -------------------------------------------------------------------------------- 1 | import { useParams, useHistory } from 'react-router-dom'; 2 | import { useState, useEffect } from 'react'; 3 | import { getUserByUsername } from '../services/firebase'; 4 | import * as ROUTES from '../constants/routes'; 5 | import Header from '../components/header'; 6 | import UserProfile from '../components/profile'; 7 | 8 | export default function Profile() { 9 | const { username } = useParams(); 10 | const [user, setUser] = useState(null); 11 | const history = useHistory(); 12 | 13 | useEffect(() => { 14 | async function checkUserExists() { 15 | const [user] = await getUserByUsername(username); 16 | if (user?.userId) { 17 | setUser(user); 18 | } else { 19 | history.push(ROUTES.NOT_FOUND); 20 | } 21 | } 22 | 23 | checkUserExists(); 24 | }, [username, history]); 25 | 26 | return user?.username ? ( 27 |
28 |
29 |
30 | 31 |
32 |
33 | ) : null; 34 | } 35 | -------------------------------------------------------------------------------- /src/pages/sign-up.js: -------------------------------------------------------------------------------- 1 | import { useState, useContext, useEffect } from 'react'; 2 | import { Link, useHistory } from 'react-router-dom'; 3 | import FirebaseContext from '../context/firebase'; 4 | import * as ROUTES from '../constants/routes'; 5 | import { doesUsernameExist } from '../services/firebase'; 6 | 7 | export default function SignUp() { 8 | const history = useHistory(); 9 | const { firebase } = useContext(FirebaseContext); 10 | 11 | const [username, setUsername] = useState(''); 12 | const [fullName, setFullName] = useState(''); 13 | const [emailAddress, setEmailAddress] = useState(''); 14 | const [password, setPassword] = useState(''); 15 | 16 | const [error, setError] = useState(''); 17 | const isInvalid = password === '' || emailAddress === ''; 18 | 19 | const handleSignUp = async (event) => { 20 | event.preventDefault(); 21 | 22 | const usernameExists = await doesUsernameExist(username); 23 | if (!usernameExists) { 24 | try { 25 | const createdUserResult = await firebase 26 | .auth() 27 | .createUserWithEmailAndPassword(emailAddress, password); 28 | 29 | // authentication 30 | // -> emailAddress & password & username (displayName) 31 | await createdUserResult.user.updateProfile({ 32 | displayName: username 33 | }); 34 | 35 | // firebase user collection (create a document) 36 | await firebase 37 | .firestore() 38 | .collection('users') 39 | .add({ 40 | userId: createdUserResult.user.uid, 41 | username: username.toLowerCase(), 42 | fullName, 43 | emailAddress: emailAddress.toLowerCase(), 44 | following: ['2'], 45 | followers: [], 46 | dateCreated: Date.now() 47 | }); 48 | 49 | history.push(ROUTES.DASHBOARD); 50 | } catch (error) { 51 | setFullName(''); 52 | setEmailAddress(''); 53 | setPassword(''); 54 | setError(error.message); 55 | } 56 | } else { 57 | setUsername(''); 58 | setError('That username is already taken, please try another.'); 59 | } 60 | }; 61 | 62 | useEffect(() => { 63 | document.title = 'Sign Up - Instagram'; 64 | }, []); 65 | 66 | return ( 67 |
68 |
69 | iPhone with Instagram app 70 |
71 |
72 |
73 |

74 | Instagram 75 |

76 | 77 | {error &&

{error}

} 78 | 79 |
80 | setUsername(target.value)} 86 | value={username} 87 | /> 88 | setFullName(target.value)} 94 | value={fullName} 95 | /> 96 | setEmailAddress(target.value)} 102 | value={emailAddress} 103 | /> 104 | setPassword(target.value)} 110 | value={password} 111 | /> 112 | 120 |
121 |
122 |
123 |

124 | Have an account?{` `} 125 | 126 | Login 127 | 128 |

129 |
130 |
131 |
132 | ); 133 | } 134 | -------------------------------------------------------------------------------- /src/seed.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-plusplus */ 2 | // NOTE: replace 'NvPY9M9MzFTARQ6M816YAzDJxZ72' with your Firebase auth user id (can be taken from Firebase) 3 | export function seedDatabase(firebase) { 4 | const users = [ 5 | { 6 | userId: 'NvPY9M9MzFTARQ6M816YAzDJxZ72', 7 | username: 'karl', 8 | fullName: 'Karl Hadwen', 9 | emailAddress: 'karlhadwen@gmail.com', 10 | following: ['2'], 11 | followers: ['2', '3', '4'], 12 | dateCreated: Date.now() 13 | }, 14 | { 15 | userId: '2', 16 | username: 'raphael', 17 | fullName: 'Raffaello Sanzio da Urbino', 18 | emailAddress: 'raphael@sanzio.com', 19 | following: [], 20 | followers: ['NvPY9M9MzFTARQ6M816YAzDJxZ72'], 21 | dateCreated: Date.now() 22 | }, 23 | { 24 | userId: '3', 25 | username: 'dali', 26 | fullName: 'Salvador Dalí', 27 | emailAddress: 'salvador@dali.com', 28 | following: [], 29 | followers: ['NvPY9M9MzFTARQ6M816YAzDJxZ72'], 30 | dateCreated: Date.now() 31 | }, 32 | { 33 | userId: '4', 34 | username: 'orwell', 35 | fullName: 'George Orwell', 36 | emailAddress: 'george@orwell.com', 37 | following: [], 38 | followers: ['NvPY9M9MzFTARQ6M816YAzDJxZ72'], 39 | dateCreated: Date.now() 40 | } 41 | ]; 42 | 43 | // eslint-disable-next-line prefer-const 44 | for (let k = 0; k < users.length; k++) { 45 | firebase.firestore().collection('users').add(users[k]); 46 | } 47 | 48 | // eslint-disable-next-line prefer-const 49 | for (let i = 1; i <= 5; ++i) { 50 | firebase 51 | .firestore() 52 | .collection('photos') 53 | .add({ 54 | photoId: i, 55 | userId: '2', 56 | imageSrc: `/images/users/raphael/${i}.jpg`, 57 | caption: 'Saint George and the Dragon', 58 | likes: [], 59 | comments: [ 60 | { 61 | displayName: 'dali', 62 | comment: 'Love this place, looks like my animal farm!' 63 | }, 64 | { 65 | displayName: 'orwell', 66 | comment: 'Would you mind if I used this picture?' 67 | } 68 | ], 69 | userLatitude: '40.7128°', 70 | userLongitude: '74.0060°', 71 | dateCreated: Date.now() 72 | }); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/services/firebase.js: -------------------------------------------------------------------------------- 1 | import { firebase, FieldValue } from '../lib/firebase'; 2 | 3 | export async function doesUsernameExist(username) { 4 | const result = await firebase 5 | .firestore() 6 | .collection('users') 7 | .where('username', '==', username.toLowerCase()) 8 | .get(); 9 | 10 | return result.docs.length > 0; 11 | } 12 | 13 | export async function getUserByUsername(username) { 14 | const result = await firebase 15 | .firestore() 16 | .collection('users') 17 | .where('username', '==', username.toLowerCase()) 18 | .get(); 19 | 20 | return result.docs.map((item) => ({ 21 | ...item.data(), 22 | docId: item.id 23 | })); 24 | } 25 | 26 | // get user from the firestore where userId === userId (passed from the auth) 27 | export async function getUserByUserId(userId) { 28 | const result = await firebase.firestore().collection('users').where('userId', '==', userId).get(); 29 | const user = result.docs.map((item) => ({ 30 | ...item.data(), 31 | docId: item.id 32 | })); 33 | 34 | return user; 35 | } 36 | 37 | // check all conditions before limit results 38 | export async function getSuggestedProfiles(userId, following) { 39 | let query = firebase.firestore().collection('users'); 40 | 41 | if (following.length > 0) { 42 | query = query.where('userId', 'not-in', [...following, userId]); 43 | } else { 44 | query = query.where('userId', '!=', userId); 45 | } 46 | const result = await query.limit(10).get(); 47 | 48 | const profiles = result.docs.map((user) => ({ 49 | ...user.data(), 50 | docId: user.id 51 | })); 52 | 53 | return profiles; 54 | } 55 | 56 | export async function updateLoggedInUserFollowing( 57 | loggedInUserDocId, // currently logged in user document id (karl's profile) 58 | profileId, // the user that karl requests to follow 59 | isFollowingProfile // true/false (am i currently following this person?) 60 | ) { 61 | return firebase 62 | .firestore() 63 | .collection('users') 64 | .doc(loggedInUserDocId) 65 | .update({ 66 | following: isFollowingProfile ? 67 | FieldValue.arrayRemove(profileId) : 68 | FieldValue.arrayUnion(profileId) 69 | }); 70 | } 71 | 72 | export async function updateFollowedUserFollowers( 73 | profileDocId, // currently logged in user document id (karl's profile) 74 | loggedInUserDocId, // the user that karl requests to follow 75 | isFollowingProfile // true/false (am i currently following this person?) 76 | ) { 77 | return firebase 78 | .firestore() 79 | .collection('users') 80 | .doc(profileDocId) 81 | .update({ 82 | followers: isFollowingProfile ? 83 | FieldValue.arrayRemove(loggedInUserDocId) : 84 | FieldValue.arrayUnion(loggedInUserDocId) 85 | }); 86 | } 87 | 88 | export async function getPhotos(userId, following) { 89 | // [5,4,2] => following 90 | const result = await firebase 91 | .firestore() 92 | .collection('photos') 93 | .where('userId', 'in', following) 94 | .get(); 95 | 96 | const userFollowedPhotos = result.docs.map((photo) => ({ 97 | ...photo.data(), 98 | docId: photo.id 99 | })); 100 | 101 | const photosWithUserDetails = await Promise.all( 102 | userFollowedPhotos.map(async (photo) => { 103 | let userLikedPhoto = false; 104 | if (photo.likes.includes(userId)) { 105 | userLikedPhoto = true; 106 | } 107 | // photo.userId = 2 108 | const user = await getUserByUserId(photo.userId); 109 | // raphael 110 | const { username } = user[0]; 111 | return { username, ...photo, userLikedPhoto }; 112 | }) 113 | ); 114 | 115 | return photosWithUserDetails; 116 | } 117 | 118 | export async function getUserPhotosByUserId(userId) { 119 | const result = await firebase 120 | .firestore() 121 | .collection('photos') 122 | .where('userId', '==', userId) 123 | .get(); 124 | 125 | const photos = result.docs.map((photo) => ({ 126 | ...photo.data(), 127 | docId: photo.id 128 | })); 129 | return photos; 130 | } 131 | 132 | export async function isUserFollowingProfile(loggedInUserUsername, profileUserId) { 133 | const result = await firebase 134 | .firestore() 135 | .collection('users') 136 | .where('username', '==', loggedInUserUsername) // karl (active logged in user) 137 | .where('following', 'array-contains', profileUserId) 138 | .get(); 139 | 140 | const [response = {}] = result.docs.map((item) => ({ 141 | ...item.data(), 142 | docId: item.id 143 | })); 144 | 145 | return response.userId; 146 | } 147 | 148 | export async function toggleFollow( 149 | isFollowingProfile, 150 | activeUserDocId, 151 | profileDocId, 152 | profileUserId, 153 | followingUserId 154 | ) { 155 | // 1st param: karl's doc id 156 | // 2nd param: raphael's user id 157 | // 3rd param: is the user following this profile? e.g. does karl follow raphael? (true/false) 158 | await updateLoggedInUserFollowing(activeUserDocId, profileUserId, isFollowingProfile); 159 | 160 | // 1st param: karl's user id 161 | // 2nd param: raphael's doc id 162 | // 3rd param: is the user following this profile? e.g. does karl follow raphael? (true/false) 163 | await updateFollowedUserFollowers(profileDocId, followingUserId, isFollowingProfile); 164 | } 165 | -------------------------------------------------------------------------------- /src/styles/app.css: -------------------------------------------------------------------------------- 1 | /*! modern-normalize v1.0.0 | MIT License | https://github.com/sindresorhus/modern-normalize */ 2 | 3 | /* 4 | Document 5 | ======== 6 | */ 7 | 8 | /** 9 | Use a better box model (opinionated). 10 | */ 11 | 12 | *, 13 | *::before, 14 | *::after { 15 | box-sizing: border-box; 16 | } 17 | 18 | /** 19 | Use a more readable tab size (opinionated). 20 | */ 21 | 22 | :root { 23 | -moz-tab-size: 4; 24 | tab-size: 4; 25 | } 26 | 27 | /** 28 | 1. Correct the line height in all browsers. 29 | 2. Prevent adjustments of font size after orientation changes in iOS. 30 | */ 31 | 32 | html { 33 | line-height: 1.15; /* 1 */ 34 | -webkit-text-size-adjust: 100%; /* 2 */ 35 | } 36 | 37 | /* 38 | Sections 39 | ======== 40 | */ 41 | 42 | /** 43 | Remove the margin in all browsers. 44 | */ 45 | 46 | body { 47 | margin: 0; 48 | } 49 | 50 | /** 51 | Improve consistency of default fonts in all browsers. (https://github.com/sindresorhus/modern-normalize/issues/3) 52 | */ 53 | 54 | body { 55 | font-family: 56 | system-ui, 57 | -apple-system, /* Firefox supports this but not yet `system-ui` */ 58 | 'Segoe UI', 59 | Roboto, 60 | Helvetica, 61 | Arial, 62 | sans-serif, 63 | 'Apple Color Emoji', 64 | 'Segoe UI Emoji'; 65 | } 66 | 67 | /* 68 | Grouping content 69 | ================ 70 | */ 71 | 72 | /** 73 | 1. Add the correct height in Firefox. 74 | 2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655) 75 | */ 76 | 77 | hr { 78 | height: 0; /* 1 */ 79 | color: inherit; /* 2 */ 80 | } 81 | 82 | /* 83 | Text-level semantics 84 | ==================== 85 | */ 86 | 87 | /** 88 | Add the correct text decoration in Chrome, Edge, and Safari. 89 | */ 90 | 91 | abbr[title] { 92 | text-decoration: underline dotted; 93 | } 94 | 95 | /** 96 | Add the correct font weight in Edge and Safari. 97 | */ 98 | 99 | b, 100 | strong { 101 | font-weight: bolder; 102 | } 103 | 104 | /** 105 | 1. Improve consistency of default fonts in all browsers. (https://github.com/sindresorhus/modern-normalize/issues/3) 106 | 2. Correct the odd 'em' font sizing in all browsers. 107 | */ 108 | 109 | code, 110 | kbd, 111 | samp, 112 | pre { 113 | font-family: 114 | ui-monospace, 115 | SFMono-Regular, 116 | Consolas, 117 | 'Liberation Mono', 118 | Menlo, 119 | monospace; /* 1 */ 120 | font-size: 1em; /* 2 */ 121 | } 122 | 123 | /** 124 | Add the correct font size in all browsers. 125 | */ 126 | 127 | small { 128 | font-size: 80%; 129 | } 130 | 131 | /** 132 | Prevent 'sub' and 'sup' elements from affecting the line height in all browsers. 133 | */ 134 | 135 | sub, 136 | sup { 137 | font-size: 75%; 138 | line-height: 0; 139 | position: relative; 140 | vertical-align: baseline; 141 | } 142 | 143 | sub { 144 | bottom: -0.25em; 145 | } 146 | 147 | sup { 148 | top: -0.5em; 149 | } 150 | 151 | /* 152 | Tabular data 153 | ============ 154 | */ 155 | 156 | /** 157 | 1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297) 158 | 2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016) 159 | */ 160 | 161 | table { 162 | text-indent: 0; /* 1 */ 163 | border-color: inherit; /* 2 */ 164 | } 165 | 166 | /* 167 | Forms 168 | ===== 169 | */ 170 | 171 | /** 172 | 1. Change the font styles in all browsers. 173 | 2. Remove the margin in Firefox and Safari. 174 | */ 175 | 176 | button, 177 | input, 178 | optgroup, 179 | select, 180 | textarea { 181 | font-family: inherit; /* 1 */ 182 | font-size: 100%; /* 1 */ 183 | line-height: 1.15; /* 1 */ 184 | margin: 0; /* 2 */ 185 | } 186 | 187 | /** 188 | Remove the inheritance of text transform in Edge and Firefox. 189 | 1. Remove the inheritance of text transform in Firefox. 190 | */ 191 | 192 | button, 193 | select { /* 1 */ 194 | text-transform: none; 195 | } 196 | 197 | /** 198 | Correct the inability to style clickable types in iOS and Safari. 199 | */ 200 | 201 | button, 202 | [type='button'], 203 | [type='submit'] { 204 | -webkit-appearance: button; 205 | } 206 | 207 | /** 208 | Remove the inner border and padding in Firefox. 209 | */ 210 | 211 | /** 212 | Restore the focus styles unset by the previous rule. 213 | */ 214 | 215 | /** 216 | Remove the additional ':invalid' styles in Firefox. 217 | See: https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737 218 | */ 219 | 220 | /** 221 | Remove the padding so developers are not caught out when they zero out 'fieldset' elements in all browsers. 222 | */ 223 | 224 | legend { 225 | padding: 0; 226 | } 227 | 228 | /** 229 | Add the correct vertical alignment in Chrome and Firefox. 230 | */ 231 | 232 | progress { 233 | vertical-align: baseline; 234 | } 235 | 236 | /** 237 | Correct the cursor style of increment and decrement buttons in Safari. 238 | */ 239 | 240 | /** 241 | 1. Correct the odd appearance in Chrome and Safari. 242 | 2. Correct the outline style in Safari. 243 | */ 244 | 245 | /** 246 | Remove the inner padding in Chrome and Safari on macOS. 247 | */ 248 | 249 | /** 250 | 1. Correct the inability to style clickable types in iOS and Safari. 251 | 2. Change font properties to 'inherit' in Safari. 252 | */ 253 | 254 | /* 255 | Interactive 256 | =========== 257 | */ 258 | 259 | /* 260 | Add the correct display in Chrome and Safari. 261 | */ 262 | 263 | summary { 264 | display: list-item; 265 | } 266 | 267 | /** 268 | * Manually forked from SUIT CSS Base: https://github.com/suitcss/base 269 | * A thin layer on top of normalize.css that provides a starting point more 270 | * suitable for web applications. 271 | */ 272 | 273 | /** 274 | * Removes the default spacing and border for appropriate elements. 275 | */ 276 | 277 | blockquote, 278 | dl, 279 | dd, 280 | h1, 281 | h2, 282 | h3, 283 | h4, 284 | h5, 285 | h6, 286 | hr, 287 | figure, 288 | p, 289 | pre { 290 | margin: 0; 291 | } 292 | 293 | button { 294 | background-color: transparent; 295 | background-image: none; 296 | } 297 | 298 | /** 299 | * Work around a Firefox/IE bug where the transparent `button` background 300 | * results in a loss of the default `button` focus styles. 301 | */ 302 | 303 | button:focus { 304 | outline: 1px dotted; 305 | outline: 5px auto -webkit-focus-ring-color; 306 | } 307 | 308 | fieldset { 309 | margin: 0; 310 | padding: 0; 311 | } 312 | 313 | ol, 314 | ul { 315 | list-style: none; 316 | margin: 0; 317 | padding: 0; 318 | } 319 | 320 | /** 321 | * Tailwind custom reset styles 322 | */ 323 | 324 | /** 325 | * 1. Use the user's configured `sans` font-family (with Tailwind's default 326 | * sans-serif font stack as a fallback) as a sane default. 327 | * 2. Use Tailwind's default "normal" line-height so the user isn't forced 328 | * to override it to ensure consistency even when using the default theme. 329 | */ 330 | 331 | html { 332 | font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; /* 1 */ 333 | line-height: 1.5; /* 2 */ 334 | } 335 | 336 | /** 337 | * Inherit font-family and line-height from `html` so users can set them as 338 | * a class directly on the `html` element. 339 | */ 340 | 341 | body { 342 | font-family: inherit; 343 | line-height: inherit; 344 | } 345 | 346 | /** 347 | * 1. Prevent padding and border from affecting element width. 348 | * 349 | * We used to set this in the html element and inherit from 350 | * the parent element for everything else. This caused issues 351 | * in shadow-dom-enhanced elements like
where the content 352 | * is wrapped by a div with box-sizing set to `content-box`. 353 | * 354 | * https://github.com/mozdevs/cssremedy/issues/4 355 | * 356 | * 357 | * 2. Allow adding a border to an element by just adding a border-width. 358 | * 359 | * By default, the way the browser specifies that an element should have no 360 | * border is by setting it's border-style to `none` in the user-agent 361 | * stylesheet. 362 | * 363 | * In order to easily add borders to elements by just setting the `border-width` 364 | * property, we change the default border-style for all elements to `solid`, and 365 | * use border-width to hide them instead. This way our `border` utilities only 366 | * need to set the `border-width` property instead of the entire `border` 367 | * shorthand, making our border utilities much more straightforward to compose. 368 | * 369 | * https://github.com/tailwindcss/tailwindcss/pull/116 370 | */ 371 | 372 | *, 373 | ::before, 374 | ::after { 375 | box-sizing: border-box; /* 1 */ 376 | border-width: 0; /* 2 */ 377 | border-style: solid; /* 2 */ 378 | border-color: currentColor; /* 2 */ 379 | } 380 | 381 | /* 382 | * Ensure horizontal rules are visible by default 383 | */ 384 | 385 | hr { 386 | border-top-width: 1px; 387 | } 388 | 389 | /** 390 | * Undo the `border-style: none` reset that Normalize applies to images so that 391 | * our `border-{width}` utilities have the expected effect. 392 | * 393 | * The Normalize reset is unnecessary for us since we default the border-width 394 | * to 0 on all elements. 395 | * 396 | * https://github.com/tailwindcss/tailwindcss/issues/362 397 | */ 398 | 399 | img { 400 | border-style: solid; 401 | } 402 | 403 | textarea { 404 | resize: vertical; 405 | } 406 | 407 | input::placeholder, 408 | textarea::placeholder { 409 | color: #a1a1aa; 410 | } 411 | 412 | button { 413 | cursor: pointer; 414 | } 415 | 416 | table { 417 | border-collapse: collapse; 418 | } 419 | 420 | h1, 421 | h2, 422 | h3, 423 | h4, 424 | h5, 425 | h6 { 426 | font-size: inherit; 427 | font-weight: inherit; 428 | } 429 | 430 | /** 431 | * Reset links to optimize for opt-in styling instead of 432 | * opt-out. 433 | */ 434 | 435 | a { 436 | color: inherit; 437 | text-decoration: inherit; 438 | } 439 | 440 | /** 441 | * Reset form element properties that are easy to forget to 442 | * style explicitly so you don't inadvertently introduce 443 | * styles that deviate from your design system. These styles 444 | * supplement a partial reset that is already applied by 445 | * normalize.css. 446 | */ 447 | 448 | button, 449 | input, 450 | optgroup, 451 | select, 452 | textarea { 453 | padding: 0; 454 | line-height: inherit; 455 | color: inherit; 456 | } 457 | 458 | /** 459 | * Use the configured 'mono' font family for elements that 460 | * are expected to be rendered with a monospace font, falling 461 | * back to the system monospace stack if there is no configured 462 | * 'mono' font family. 463 | */ 464 | 465 | pre, 466 | code, 467 | kbd, 468 | samp { 469 | font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; 470 | } 471 | 472 | /** 473 | * Make replaced elements `display: block` by default as that's 474 | * the behavior you want almost all of the time. Inspired by 475 | * CSS Remedy, with `svg` added as well. 476 | * 477 | * https://github.com/mozdevs/cssremedy/issues/14 478 | */ 479 | 480 | img, 481 | svg, 482 | video, 483 | canvas, 484 | audio, 485 | iframe, 486 | embed, 487 | object { 488 | display: block; 489 | vertical-align: middle; 490 | } 491 | 492 | /** 493 | * Constrain images and videos to the parent width and preserve 494 | * their instrinsic aspect ratio. 495 | * 496 | * https://github.com/mozdevs/cssremedy/issues/14 497 | */ 498 | 499 | img, 500 | video { 501 | max-width: 100%; 502 | height: auto; 503 | } 504 | 505 | .container { 506 | width: 100%; 507 | } 508 | 509 | @media (min-width: 640px) { 510 | .container { 511 | max-width: 640px; 512 | } 513 | } 514 | 515 | @media (min-width: 768px) { 516 | .container { 517 | max-width: 768px; 518 | } 519 | } 520 | 521 | @media (min-width: 1024px) { 522 | .container { 523 | max-width: 1024px; 524 | } 525 | } 526 | 527 | @media (min-width: 1280px) { 528 | .container { 529 | max-width: 1280px; 530 | } 531 | } 532 | 533 | @media (min-width: 1536px) { 534 | .container { 535 | max-width: 1536px; 536 | } 537 | } 538 | 539 | .bg-white { 540 | --tw-bg-opacity: 1; 541 | background-color: rgba(255, 255, 255, var(--tw-bg-opacity)); 542 | } 543 | 544 | .bg-blue-medium { 545 | --tw-bg-opacity: 1; 546 | background-color: rgba(0, 92, 152, var(--tw-bg-opacity)); 547 | } 548 | 549 | .bg-black-faded { 550 | background-color: #00000059; 551 | } 552 | 553 | .bg-gray-background { 554 | --tw-bg-opacity: 1; 555 | background-color: rgba(250, 250, 250, var(--tw-bg-opacity)); 556 | } 557 | 558 | .border-gray-primary { 559 | --tw-border-opacity: 1; 560 | border-color: rgba(219, 219, 219, var(--tw-border-opacity)); 561 | } 562 | 563 | .rounded { 564 | border-radius: 0.25rem; 565 | } 566 | 567 | .rounded-full { 568 | border-radius: 9999px; 569 | } 570 | 571 | .border { 572 | border-width: 1px; 573 | } 574 | 575 | .border-t { 576 | border-top-width: 1px; 577 | } 578 | 579 | .border-b { 580 | border-bottom-width: 1px; 581 | } 582 | 583 | .cursor-pointer { 584 | cursor: pointer; 585 | } 586 | 587 | .flex { 588 | display: flex; 589 | } 590 | 591 | .table { 592 | display: table; 593 | } 594 | 595 | .grid { 596 | display: grid; 597 | } 598 | 599 | .hidden { 600 | display: none; 601 | } 602 | 603 | .group:hover .group-hover\:flex { 604 | display: flex; 605 | } 606 | 607 | .flex-row { 608 | flex-direction: row; 609 | } 610 | 611 | .flex-col { 612 | flex-direction: column; 613 | } 614 | 615 | .items-center { 616 | align-items: center; 617 | } 618 | 619 | .justify-center { 620 | justify-content: center; 621 | } 622 | 623 | .justify-between { 624 | justify-content: space-between; 625 | } 626 | 627 | .justify-evenly { 628 | justify-content: space-evenly; 629 | } 630 | 631 | .font-medium { 632 | font-weight: 500; 633 | } 634 | 635 | .font-bold { 636 | font-weight: 700; 637 | } 638 | 639 | .h-2 { 640 | height: 0.5rem; 641 | } 642 | 643 | .h-4 { 644 | height: 1rem; 645 | } 646 | 647 | .h-8 { 648 | height: 2rem; 649 | } 650 | 651 | .h-16 { 652 | height: 4rem; 653 | } 654 | 655 | .h-40 { 656 | height: 10rem; 657 | } 658 | 659 | .h-full { 660 | height: 100%; 661 | } 662 | 663 | .h-screen { 664 | height: 100vh; 665 | } 666 | 667 | .text-xs { 668 | font-size: 0.75rem; 669 | line-height: 1rem; 670 | } 671 | 672 | .text-sm { 673 | font-size: 0.875rem; 674 | line-height: 1.25rem; 675 | } 676 | 677 | .text-2xl { 678 | font-size: 1.5rem; 679 | line-height: 2rem; 680 | } 681 | 682 | .mx-auto { 683 | margin-left: auto; 684 | margin-right: auto; 685 | } 686 | 687 | .mr-1 { 688 | margin-right: 0.25rem; 689 | } 690 | 691 | .mb-1 { 692 | margin-bottom: 0.25rem; 693 | } 694 | 695 | .mt-2 { 696 | margin-top: 0.5rem; 697 | } 698 | 699 | .mb-2 { 700 | margin-bottom: 0.5rem; 701 | } 702 | 703 | .mr-3 { 704 | margin-right: 0.75rem; 705 | } 706 | 707 | .mt-4 { 708 | margin-top: 1rem; 709 | } 710 | 711 | .mr-4 { 712 | margin-right: 1rem; 713 | } 714 | 715 | .mb-4 { 716 | margin-bottom: 1rem; 717 | } 718 | 719 | .mt-5 { 720 | margin-top: 1.25rem; 721 | } 722 | 723 | .mb-5 { 724 | margin-bottom: 1.25rem; 725 | } 726 | 727 | .mr-6 { 728 | margin-right: 1.5rem; 729 | } 730 | 731 | .mb-6 { 732 | margin-bottom: 1.5rem; 733 | } 734 | 735 | .mb-8 { 736 | margin-bottom: 2rem; 737 | } 738 | 739 | .mr-10 { 740 | margin-right: 2.5rem; 741 | } 742 | 743 | .mt-12 { 744 | margin-top: 3rem; 745 | } 746 | 747 | .mb-12 { 748 | margin-bottom: 3rem; 749 | } 750 | 751 | .max-w-screen-md { 752 | max-width: 768px; 753 | } 754 | 755 | .max-w-screen-lg { 756 | max-width: 1024px; 757 | } 758 | 759 | .opacity-25 { 760 | opacity: 0.25; 761 | } 762 | 763 | .opacity-50 { 764 | opacity: 0.5; 765 | } 766 | 767 | .p-4 { 768 | padding: 1rem; 769 | } 770 | 771 | .py-0 { 772 | padding-top: 0px; 773 | padding-bottom: 0px; 774 | } 775 | 776 | .px-4 { 777 | padding-left: 1rem; 778 | padding-right: 1rem; 779 | } 780 | 781 | .py-5 { 782 | padding-top: 1.25rem; 783 | padding-bottom: 1.25rem; 784 | } 785 | 786 | .py-8 { 787 | padding-top: 2rem; 788 | padding-bottom: 2rem; 789 | } 790 | 791 | .pb-0 { 792 | padding-bottom: 0px; 793 | } 794 | 795 | .pl-0 { 796 | padding-left: 0px; 797 | } 798 | 799 | .pt-1 { 800 | padding-top: 0.25rem; 801 | } 802 | 803 | .pt-2 { 804 | padding-top: 0.5rem; 805 | } 806 | 807 | .pt-4 { 808 | padding-top: 1rem; 809 | } 810 | 811 | .pb-4 { 812 | padding-bottom: 1rem; 813 | } 814 | 815 | .pr-5 { 816 | padding-right: 1.25rem; 817 | } 818 | 819 | .absolute { 820 | position: absolute; 821 | } 822 | 823 | .relative { 824 | position: relative; 825 | } 826 | 827 | .bottom-0 { 828 | bottom: 0px; 829 | } 830 | 831 | .left-0 { 832 | left: 0px; 833 | } 834 | 835 | * { 836 | --tw-shadow: 0 0 #0000; 837 | } 838 | 839 | * { 840 | --tw-ring-inset: var(--tw-empty,/*!*/ /*!*/); 841 | --tw-ring-offset-width: 0px; 842 | --tw-ring-offset-color: #fff; 843 | --tw-ring-color: rgba(59, 130, 246, 0.5); 844 | --tw-ring-offset-shadow: 0 0 #0000; 845 | --tw-ring-shadow: 0 0 #0000; 846 | } 847 | 848 | .fill-red { 849 | fill: #ed4956; 850 | } 851 | 852 | .text-center { 853 | text-align: center; 854 | } 855 | 856 | .text-white { 857 | --tw-text-opacity: 1; 858 | color: rgba(255, 255, 255, var(--tw-text-opacity)); 859 | } 860 | 861 | .text-blue-medium { 862 | --tw-text-opacity: 1; 863 | color: rgba(0, 92, 152, var(--tw-text-opacity)); 864 | } 865 | 866 | .text-black-light { 867 | --tw-text-opacity: 1; 868 | color: rgba(38, 38, 38, var(--tw-text-opacity)); 869 | } 870 | 871 | .text-gray-base { 872 | --tw-text-opacity: 1; 873 | color: rgba(97, 97, 97, var(--tw-text-opacity)); 874 | } 875 | 876 | .text-red-primary { 877 | --tw-text-opacity: 1; 878 | color: rgba(237, 73, 86, var(--tw-text-opacity)); 879 | } 880 | 881 | .uppercase { 882 | text-transform: uppercase; 883 | } 884 | 885 | .select-none { 886 | -webkit-user-select: none; 887 | user-select: none; 888 | } 889 | 890 | .w-8 { 891 | width: 2rem; 892 | } 893 | 894 | .w-16 { 895 | width: 4rem; 896 | } 897 | 898 | .w-20 { 899 | width: 5rem; 900 | } 901 | 902 | .w-40 { 903 | width: 10rem; 904 | } 905 | 906 | .w-2\/5 { 907 | width: 40%; 908 | } 909 | 910 | .w-3\/5 { 911 | width: 60%; 912 | } 913 | 914 | .w-6\/12 { 915 | width: 50%; 916 | } 917 | 918 | .w-full { 919 | width: 100%; 920 | } 921 | 922 | .z-10 { 923 | z-index: 10; 924 | } 925 | 926 | .gap-4 { 927 | gap: 1rem; 928 | } 929 | 930 | .gap-5 { 931 | gap: 1.25rem; 932 | } 933 | 934 | .gap-8 { 935 | gap: 2rem; 936 | } 937 | 938 | .grid-cols-3 { 939 | grid-template-columns: repeat(3, minmax(0, 1fr)); 940 | } 941 | 942 | .grid-cols-4 { 943 | grid-template-columns: repeat(4, minmax(0, 1fr)); 944 | } 945 | 946 | .col-span-1 { 947 | grid-column: span 1 / span 1; 948 | } 949 | 950 | .col-span-2 { 951 | grid-column: span 2 / span 2; 952 | } 953 | 954 | .col-span-3 { 955 | grid-column: span 3 / span 3; 956 | } 957 | 958 | .col-span-4 { 959 | grid-column: span 4 / span 4; 960 | } 961 | 962 | @keyframes spin { 963 | to { 964 | transform: rotate(360deg); 965 | } 966 | } 967 | 968 | @keyframes ping { 969 | 75%, 100% { 970 | transform: scale(2); 971 | opacity: 0; 972 | } 973 | } 974 | 975 | @keyframes pulse { 976 | 50% { 977 | opacity: .5; 978 | } 979 | } 980 | 981 | @keyframes bounce { 982 | 0%, 100% { 983 | transform: translateY(-25%); 984 | animation-timing-function: cubic-bezier(0.8,0,1,1); 985 | } 986 | 987 | 50% { 988 | transform: none; 989 | animation-timing-function: cubic-bezier(0,0,0.2,1); 990 | } 991 | } 992 | 993 | @media (min-width: 640px) { 994 | } 995 | 996 | @media (min-width: 768px) { 997 | } 998 | 999 | @media (min-width: 1024px) { 1000 | } 1001 | 1002 | @media (min-width: 1280px) { 1003 | } 1004 | 1005 | @media (min-width: 1536px) { 1006 | } 1007 | -------------------------------------------------------------------------------- /src/styles/tailwind.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | future: { 3 | removeDeprecatedGapUtilities: true 4 | }, 5 | purge: { 6 | content: ['./src/**/*.js', './src/**/**/*.js'] 7 | }, 8 | theme: { 9 | fill: (theme) => ({ 10 | red: theme('colors.red.primary') 11 | }), 12 | colors: { 13 | white: '#ffffff', 14 | blue: { 15 | medium: '#005c98' 16 | }, 17 | black: { 18 | light: '#262626', 19 | faded: '#00000059' 20 | }, 21 | gray: { 22 | base: '#616161', 23 | background: '#fafafa', 24 | primary: '#dbdbdb' 25 | }, 26 | red: { 27 | primary: '#ed4956' 28 | } 29 | } 30 | }, 31 | variants: { 32 | extend: { 33 | display: ['group-hover'] 34 | } 35 | } 36 | }; 37 | --------------------------------------------------------------------------------