├── .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 | 
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 |
23 |
24 |
25 |
26 |
27 | {loggedInUser ? (
28 | <>
29 |
30 |
44 |
45 |
46 |
75 | {user && (
76 |
77 |
78 |

{
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 |
55 |
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 |
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 |

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
;
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 |

{
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 |

15 |
16 |
17 |
18 |
30 | {photo.likes.length}
31 |
32 |
33 |
34 |
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 |

37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 | {error &&
{error}
}
45 |
46 |
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 |
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 |
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 |

70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 | {error &&
{error}
}
78 |
79 |
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 |
--------------------------------------------------------------------------------