├── .gitignore ├── README.md ├── package-lock.json ├── package.json ├── public ├── favicon.ico ├── index.html └── manifest.json ├── src ├── components │ ├── AddComment.jsx │ ├── AddPost.jsx │ ├── Application.jsx │ ├── Authentication.jsx │ ├── Comment.jsx │ ├── Comments.jsx │ ├── CurrentUser.jsx │ ├── Post.jsx │ ├── Posts.jsx │ ├── SignIn.jsx │ ├── SignInAndSignUp.jsx │ └── SignUp.jsx ├── index.js └── index.scss └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Firebase && React 2 | 3 | ## Initial Set Up 4 | 5 | - Take a tour of the application. 6 | - Set up a new project in the Firebase console. 7 | - Take a tour of the Firebase console. 8 | - Go to the Database section and create a new Cloud Firestore. 9 | - Put it into test mode. 10 | 11 | ## Installing Firebase in Your React Application 12 | 13 | Let's make a new file called `firebase.js`. 14 | 15 | ```js 16 | import firebase from 'firebase/app'; 17 | 18 | const config = { 19 | apiKey: 'AIzaSyAudsj8rc2TsUjwUx1ISskz-FPwEYuYlCw', 20 | authDomain: 'think-piece.firebaseapp.com', 21 | databaseURL: 'https://think-piece.firebaseio.com', 22 | projectId: 'think-piece', 23 | storageBucket: 'think-piece.appspot.com', 24 | messagingSenderId: '98218894562', 25 | }; 26 | 27 | firebase.initializeApp(config); 28 | 29 | export default firebase; 30 | ``` 31 | 32 | Explain the following: 33 | 34 | - The apiKey just associates you with a Firebase project. We don't need to hide it. 35 | - Your project will be protected by security rules later. 36 | - There is a second, more important key that we'll use later that *should* be hidden. 37 | - We're just pulling in `firebase/app` so that we don't end up pulling in more than we need in our client-side application. 38 | - We configure Firebase and then we'll export it for use in other places in our application. 39 | 40 | ### Setting Up Cloud Firestore 41 | 42 | This basic installation of firebase does *not* include Cloud Firestore. So, let's get that in place as well. 43 | 44 | ```js 45 | import firebase from 'firebase/app'; 46 | import 'firebase/firestore'; // NEW 47 | 48 | const config = { 49 | apiKey: 'AIzaSyAudsj8rc2TsUjwUx1ISskz-FPwEYuYlCw', 50 | authDomain: 'think-piece.firebaseapp.com', 51 | databaseURL: 'https://think-piece.firebaseio.com', 52 | projectId: 'think-piece', 53 | storageBucket: 'think-piece.appspot.com', 54 | messagingSenderId: '98218894562', 55 | }; 56 | 57 | firebase.initializeApp(config); 58 | 59 | export const firestore = firebase.firestore(); // NEW 60 | 61 | export default firebase; 62 | ``` 63 | 64 | ## Cloud Firestore 65 | 66 | ### Fetching Posts from Cloud Firestore 67 | 68 | Let's start by fetching posts whenenver the `Application` component mounts. 69 | 70 | First, let's pull in Cloud Firestore from our new `firebase.js` file. 71 | 72 | ```js 73 | import { firestore } from '../firebase'; 74 | ``` 75 | 76 | Now, we'll get all of the posts from Cloud Firestore whenenver the `Application` component mounts. 77 | 78 | ```js 79 | componentDidMount = async () => { 80 | const posts = await firestore.collection('posts').get(); 81 | 82 | console.log(posts); 83 | } 84 | ``` 85 | 86 | Hmm… that looks like a `QuerySnapshot` not our posts. What is that? 87 | 88 | ### QuerySnapshots 89 | 90 | 91 | 92 | A `QuerySnapshot` has the following properties: 93 | 94 | - `docs`: All of the documents in the snapshot. 95 | - `empty`: This is a boolean that lets us know if the snapshot was empty. 96 | - `metadata`: Metadata about this snapshot, concerning its source and if it has local modifications. 97 | - Example: `SnapshotMetadata {hasPendingWrites: false, fromCache: false}` 98 | - `query`: A reference to the query that you fired. 99 | - `size`: The number of documents in the `QuerySnapshot`. 100 | 101 | …and the following methods: 102 | 103 | - `docChanges()`: An array of the changes since the last snapshot. 104 | - `forEach()`: Iterates over the entire array of snapshots. 105 | - `isEqual()`: Let's you know if it matches another snapshot. 106 | 107 | `QuerySnapshots` typically hold onto a number `QueryDocumentSnapshot`s, which inherit from `DocumentSnapshot` and have the following properties: 108 | 109 | - `id`: The `id` of the given document. 110 | - `exists`: Is this even a thing in the database? 111 | - `metadata`: Pretty much the same as `QuerySnapshot` above. 112 | - `ref`: A reference to the the documents location in the database. 113 | 114 | …and the following methods: 115 | 116 | - `data()`: Gets all of the fields of the object. 117 | - `get()`: Allows you to access a particular property on the object. 118 | - `isEqual()`: Useful for comparisons. 119 | 120 | References allow you to access the database itself. This is useful for getting the collection that document is from, deleting the document, listening for changes, setting and updating properties. 121 | 122 | ### Dealing With That Gnarly Error 123 | 124 | You'll notice that we have a very mean error message at the top of our console. Cloud Firestore made an API change that we need to opt into. This is a new application, so that seems fine. 125 | 126 | ```js 127 | firestore.settings({ timestampsInSnapshots: true }); 128 | ``` 129 | 130 | Now the error should be gone. 131 | 132 | ### Iteraring Through Documents 133 | 134 | So, now let's iterate through all zero of our documents. 135 | 136 | ```js 137 | componentDidMount = async () => { 138 | const snapshot = await firestore.collection('posts').get(); 139 | 140 | snapshot.forEach(doc => { 141 | const id = doc.id; 142 | const data = doc.data(); 143 | 144 | console.log({ id, data }); 145 | }); 146 | } 147 | ``` 148 | 149 | There won't be a lot to see here. Let's go into the Cloud Firestore console and create a document. 150 | 151 | Now, we should see it in the console. 152 | 153 | ```js 154 | componentDidMount = async () => { 155 | const snapshot = await firestore.collection('posts').get(); 156 | 157 | const posts = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() })); 158 | 159 | this.setState({ posts }); 160 | } 161 | ``` 162 | 163 | An aside, combining the document IDs with the data is something we're going to be doing a lot. Let's make a utility method in `utilities.js`: 164 | 165 | ```js 166 | export const collectIdsAndData = doc => ({ id: doc.id, ...doc.data() }) 167 | ``` 168 | 169 | Now, we'll refactor that code as follows in `Application.js`: 170 | 171 | ```js 172 | componentDidMount = async () => { 173 | const snapshot = await firestore.collection('posts').get(); 174 | 175 | const posts = snapshot.docs.map(collectIdsAndData); 176 | 177 | this.setState({ posts }); 178 | } 179 | ``` 180 | 181 | Now, we can rid of the those posts in state. 182 | 183 | ```js 184 | state = { 185 | posts: [], 186 | }; 187 | ``` 188 | 189 | ### Adding a New Post 190 | 191 | First of all, we need to get rid of that `Date.now()` based `id` in `AddPost`. It was useful for us for a second or two there, but now have Firebase generating for us on our behalf. 192 | 193 | ```js 194 | handleCreate = async post => { 195 | const docRef = await firestore.collection('posts').add(post); 196 | const doc = await docRef.get(); 197 | 198 | const newPost = { 199 | id: doc.id, 200 | ...doc.data(), 201 | }; 202 | 203 | const { posts } = this.state; 204 | this.setState({ posts: [newPost, ...posts] }); 205 | }; 206 | ``` 207 | 208 | **Important**: Get rid of the automatically generated date-based ID! 209 | 210 | ### Removing a Post 211 | 212 | In `Application.js`: 213 | 214 | ```js 215 | import React, { Component } from 'react'; 216 | 217 | import Posts from './Posts'; 218 | import { firestore } from '../firebase'; 219 | 220 | class Application extends Component { 221 | // … 222 | 223 | handleRemove = async (id) => { // NEW 224 | const allPosts = this.state.posts; 225 | 226 | const posts = allPosts.filter(post => id !== post.id); 227 | 228 | this.setState({ posts }); 229 | }; 230 | 231 | render() { 232 | const { posts } = this.state; 233 | 234 | return ( 235 |
236 |

Think Piece

237 | 242 |
243 | ); 244 | } 245 | } 246 | 247 | export default Application; 248 | ``` 249 | 250 | In `Posts.js`: 251 | 252 | ```js 253 | const Posts = ({ posts, onCreate, onRemove /* NEW */ }) => { 254 | return ( 255 |
256 | 257 | {posts.map(post => ( 258 | /* NEW */ 259 | ))} 260 |
261 | ); 262 | }; 263 | ``` 264 | 265 | In `Post.js`: 266 | 267 | ```js 268 | 269 | ``` 270 | 271 | Now, we need to actually remove it from the Firestore. 272 | 273 | ```js 274 | handleRemove = async (id) => { // NEW 275 | const allPosts = this.state.posts; 276 | 277 | const posts = allPosts.filter(post => id !== post.id); 278 | 279 | await firestore.doc(`posts/${id}`).delete(); 280 | 281 | this.setState({ posts }); 282 | }; 283 | ``` 284 | 285 | ### Subscribing to Changes 286 | 287 | Instead of managing data manually, you can also subscribe to changes in the database. Instead of a `.get()` on the collection. You'd go with `.onSnapshot()`. 288 | 289 | ```js 290 | import React, { Component } from 'react'; 291 | 292 | import Posts from './Posts'; 293 | import { firestore } from '../firebase'; 294 | 295 | class Application extends Component { 296 | state = { 297 | posts: [], 298 | }; 299 | 300 | unsubscribe = null; // NEW 301 | 302 | componentDidMount = async () => { 303 | this.unsubscribe = firestore.collection('posts').onSnapshot(snapshot => { // NEW 304 | const posts = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() })); 305 | this.setState({ posts }); 306 | }); 307 | }; 308 | 309 | componentWillUnmount = () => { // NEW 310 | this.unsubscribe(); 311 | } 312 | 313 | handleCreate = async post => { 314 | const docRef = await firestore.collection('posts').add(post); 315 | // const doc = await docRef.get(); 316 | 317 | // const newPost = { 318 | // id: doc.id, 319 | // ...doc.data(), 320 | // }; 321 | 322 | // const { posts } = this.state; 323 | // this.setState({ posts: [newPost, ...posts] }); 324 | }; 325 | 326 | handleRemove = async (id) => { 327 | // const allPosts = this.state.posts; 328 | 329 | try { 330 | await firestore.collection('posts').doc(id).delete(); 331 | // const posts = allPosts.filter(post => id !== post.id); 332 | // this.setState({ posts }); 333 | } catch (error) { 334 | console.error(error); 335 | } 336 | }; 337 | 338 | render() { 339 | // … 340 | } 341 | } 342 | 343 | export default Application; 344 | ``` 345 | 346 | #### Refactoring 347 | 348 | In `Post.jsx`: 349 | 350 | ```js 351 | 354 | ``` 355 | 356 | In `AddPost.js`: 357 | 358 | ```js 359 | handleSubmit = async event => { 360 | event.preventDefault(); 361 | 362 | const { title, content } = this.state; 363 | 364 | const post = { 365 | title, 366 | content, 367 | user: { 368 | uid: '1111', 369 | displayName: 'Steve Kinney', 370 | email: 'steve@mailinator.com', 371 | photoURL: 'http://placekitten.com/g/200/200', 372 | }, 373 | favorites: 0, 374 | comments: 0, 375 | createdAt: new Date(), 376 | } 377 | 378 | firestore.collection('posts').add(post); // NEW 379 | 380 | this.setState({ title: '', content: '' }); 381 | }; 382 | ``` 383 | 384 | In `Application.jsx`: 385 | 386 | - Remove the `handleCreate` method completely. 387 | - Remove the `handleRemove` method completely. 388 | - Remove `onCreate` and `onRemove` from the `` component in the `render()` method. 389 | 390 | ### Getting the Ordering Right 391 | 392 | ```js 393 | this.unsubscribe = firestore.collection('posts').orderBy('createdAt', 'desc').onSnapshot(snapshot => { // NEW 394 | const posts = snapshot.docs.map(collectIdsAndData); 395 | this.setState({ posts }); 396 | }); 397 | ``` 398 | 399 | ### Using Firestore's Timestamps 400 | 401 | Remember when we calmed Firebase down about timestamps? Take a good hard look at the date of the new posts we're creating. Uh oh! Moment.js is choking on invalid dates. This is because we're getting special `Timestamp` objects back from Firebase. We need to convert these to dates. 402 | 403 | In `Post.jsx`: 404 | 405 | ```js 406 | moment(createdAt.toDate()).calendar() 407 | ``` 408 | 409 | ### Exercise: Updating Documents 410 | 411 | Let's implement a naive approach to updating documents in Cloud Firestore. 412 | 413 | We have that "Star" button. When a user clicks the "Star" button, we should increment the Stars on a post. We'll eventually write a better implementation of this. 414 | 415 | #### Solution 416 | 417 | ```js 418 | 429 | ``` 430 | 431 | #### Quick Refactoring 432 | 433 | ```js 434 | const postRef = firestore.doc(`posts/${id}`); 435 | 436 | //… 437 | 438 |
439 | 440 | 441 |
442 | ``` 443 | 444 | ## Authentication 445 | 446 | Right now, the application is wide open. If we pushed this to production, any user could do literally anything they wanted to our database. That's not good. 447 | 448 | Let's implement authentication in our application. 449 | 450 | First, let's head over to the dashboard and turn on some authentication. We'll be using two forms of authentication. 451 | 452 | - Email and password authentication 453 | - Google sign-in 454 | 455 | Let's go an turn those on. 456 | 457 | ### Wiring the Current User Up to Application State 458 | 459 | Let's store the current user in the state of the `Application` component for now. 460 | 461 | ```js 462 | state = { 463 | posts: [], 464 | user: null 465 | }; 466 | ``` 467 | 468 | Cool.We have a `CurrentUser`, `SignIn`, and `SignUp` components ready to rock. 469 | 470 | We're going to start with Google Sign-in because I can assume you have a Google account if you can create a Firebase application. 471 | 472 | In `Application.jsx`: 473 | 474 | ```js 475 | render() { 476 | const { posts, user } = this.state; 477 | 478 | return ( 479 |
480 |

Think Piece

481 | {user ? : } 482 | 483 |
484 | ); 485 | } 486 | ``` 487 | 488 | In `firebase.js`: 489 | 490 | ```js 491 | import 'firebase/auth'; 492 | 493 | // … 494 | 495 | export const auth = firebase.auth(); 496 | export const provider = new firebase.auth.GoogleAuthProvider(); 497 | export const signInWithGoogle = () => auth.signInWithPopup(provider); 498 | ``` 499 | 500 | In `SignIn.jsx`: 501 | 502 | ```js 503 | 504 | ``` 505 | 506 | ### Updating Based on Authentication State 507 | 508 | In `Application.jsx`: 509 | 510 | ```js 511 | unsubscribeFromFirestore = null; 512 | unsubscribeFromAuth = null; 513 | 514 | componentDidMount = async () => { 515 | this.unsubscribeFromFirestore = firestore 516 | .collection('posts') 517 | .onSnapshot(snapshot => { 518 | const posts = snapshot.docs.map(collectIdsAndData); 519 | this.setState({ posts }); 520 | }); 521 | 522 | this.unsubscribeFromAuth = auth.onAuthStateChanged(user => { 523 | this.setState({ user }); 524 | }); 525 | }; 526 | 527 | componentWillUnmount = () => { 528 | this.unsubscribeFromFirestore(); 529 | this.unsubscribeFromAuth(); 530 | }; 531 | ``` 532 | 533 | ### Exercise: Implement Sign Out 534 | 535 | I'll add this to `firebase.js`: 536 | 537 | ```js 538 | export const signOut = () => auth.signOut(); 539 | ``` 540 | 541 | This one is pretty simple. There is a method called `auth.signOut()`. Can you write it up to the "Sign Out" button? 542 | 543 | #### Solution 544 | 545 | In `CurrentUser.jsx`: 546 | 547 | ```js 548 | 549 | ``` 550 | 551 | ### Showing the Right Component The First TIme 552 | 553 | ```js 554 | state = { 555 | posts: [], 556 | user: null, 557 | userLoaded: false 558 | }; 559 | ``` 560 | 561 | ```js 562 | this.unsubscribeFromAuth = auth.onAuthStateChanged(user => { 563 | this.setState({ user, userLoaded: true }); 564 | }); 565 | ``` 566 | 567 | ```js 568 | render() { 569 | const { posts, user, userLoaded } = this.state; 570 | 571 | const userInformation = user ? : 572 | 573 | return ( 574 |
575 |

Think Piece

576 | { userLoaded && userInformation } 577 | 578 |
579 | ); 580 | } 581 | ``` 582 | 583 | ## Security Rules 584 | 585 | Up until now, everything has been wide open. That's not great. If we're going to push stuff out to production, we're going to need to start adding some security to our application. 586 | 587 | 588 | 589 | Cloud Firestore rules always following this structure: 590 | 591 | ``` 592 | service cloud.firestore { 593 | match /databases/{database}/documents { 594 | // ... 595 | } 596 | } 597 | ``` 598 | 599 | There is a nice query pattern for rules: 600 | 601 | ``` 602 | service cloud.firestore { 603 | match /databases/{database}/documents { 604 | match /posts/{postId} { 605 | allow read: if ; 606 | allow write: if ; 607 | } 608 | } 609 | } 610 | ``` 611 | 612 | You can combine them into one: 613 | 614 | ``` 615 | service cloud.firestore { 616 | match /databases/{database}/documents { 617 | match /posts/{postId} { 618 | allow read, write: if ; 619 | } 620 | } 621 | } 622 | ``` 623 | 624 | You can get a bit more granular if you'd like: 625 | 626 | - `read` 627 | - `get` 628 | - `list` 629 | - `write` 630 | - `create` 631 | - `update` 632 | - `delete` 633 | 634 | You can nest rules to sub-collections: 635 | 636 | ``` 637 | service cloud.firestore { 638 | match /databases/{database}/documents { 639 | match /posts/{postId} { 640 | match /comments/{comment} { 641 | allow read, write: if ; 642 | } 643 | } 644 | } 645 | } 646 | ``` 647 | 648 | If you want to go to an arbitrary depth, then you can do `{document=**}`. 649 | 650 | **Important**: If multiple rules match, then the operation is allowed if _any_ of them are true. 651 | 652 | ### Practical Examples 653 | 654 | Only read or write if you're logged in. 655 | 656 | ``` 657 | service cloud.firestore { 658 | match /databases/{database}/documents { 659 | // Allow the user to access documents in the "posts" collection 660 | // only if they are authenticated. 661 | match /posts/{postId} { 662 | allow read, write: if request.auth.uid != null; 663 | } 664 | } 665 | } 666 | ``` 667 | 668 | Only read and write your own data: 669 | 670 | ``` 671 | service cloud.firestore { 672 | match /databases/{database}/documents { 673 | match /users/{userId} { 674 | allow read, update, delete: if request.auth.uid == userId; 675 | allow create: if request.auth.uid != null; 676 | } 677 | } 678 | } 679 | ``` 680 | 681 | ### Validating Based on the Document 682 | 683 | - `resource.data` will have the fields on the document as it is stored in the database. 684 | - `request.resource.data` will have the incoming document. (**Note**: This is all you have if you're responding to document creation.) 685 | 686 | ### Accessing Other Documents 687 | 688 | - `exists(/databases/$(database)/documents/users/$(request.auth.uid))` will verify that a document exists. 689 | - `get(/databases/$(database)/documents/users/$(request.auth.uid)).data` will get you the data of another document. 690 | 691 | You can write JavaScript functions to make stuff easier if you want. 692 | 693 | 694 | 695 | ### Tasting Notes 696 | 697 | - Security rules are all or nothing 698 | - You can limit the size of a query so that malicious users (or you after a big lunch) can't run expensive queries 699 | - `allow list: if request.query.limit <= 10;` 700 | 701 | ### The Current Defaults 702 | 703 | This is what we have by default: 704 | 705 | ``` 706 | service cloud.firestore { 707 | match /databases/{database}/documents { 708 | match /{document=**} { 709 | allow read, write; 710 | } 711 | } 712 | } 713 | ``` 714 | 715 | Wide open for anyone and anything. Not cool. 716 | 717 | Hit publish! Cool. Now things blow up. 718 | 719 | Let's make it so that authenticated users can add posts. 720 | 721 | ### Only Allowing Posts If Logged In 722 | 723 | ``` 724 | service cloud.firestore { 725 | match /databases/{database}/documents { 726 | match /posts/{postId} { 727 | allow read; 728 | allow write: if request.auth.uid != null; 729 | } 730 | } 731 | } 732 | ``` 733 | 734 | **Note**: Now that this can fail, let's add some better error handling. 735 | 736 | Okay, so now any logged in user can also delete any other user's posts… 737 | 738 | ### Users Can Only Delete Their Own Posts 739 | 740 | ``` 741 | service cloud.firestore { 742 | match /databases/{database}/documents { 743 | match /posts/{postId} { 744 | allow read; 745 | allow create: if request.auth.uid != null; 746 | allow update, delete: if request.auth.uid == resource.data.user.uid; 747 | } 748 | } 749 | } 750 | ``` 751 | 752 | That's better. 753 | 754 | ### Exercise: Validating Data 755 | 756 | Can you create a rule that insists on a title? 757 | 758 | #### Solution 759 | 760 | ``` 761 | service cloud.firestore { 762 | match /databases/{database}/documents { 763 | match /posts/{postId} { 764 | allow read; 765 | allow create: if request.auth.uid != null && !request.resource.data.title; 766 | allow update, delete: if request.auth.uid == resource.data.user.uid; 767 | } 768 | } 769 | } 770 | ``` 771 | 772 | ## Implementing Sign Up with Email Authentication 773 | 774 | In `SignUp.jsx`: 775 | 776 | ```js 777 | handleSubmit = async event => { 778 | event.preventDefault(); 779 | 780 | const { email, password, displayName } = this.state; 781 | 782 | try { 783 | const { user } = await auth.createUserWithEmailAndPassword( 784 | email, 785 | password, 786 | ); 787 | 788 | user.updateProfile({ displayName }); 789 | } catch (error) { 790 | alert(error); 791 | } 792 | 793 | this.setState({ displayName: '', email: '', password: '' }); 794 | }; 795 | ``` 796 | 797 | This has some problems: 798 | 799 | - The display name won't update immediately. 800 | - There is no `photoURL` because we didn't get one for free. 801 | - We may want to store other information beyond what we get from the use profile. 802 | 803 | The solution? Create documents for user profiles in Cloud Firestore. 804 | 805 | ## Storing User Information in Cloud Firestore 806 | 807 | The information on the user object is great, but we're going to run into limitations *real* quick. 808 | 809 | - What if we want to let the user set a bio or something? 810 | - What we want to set admin permissions on the users? 811 | - What we we want to keep track of what posts that a user has favorited? 812 | 813 | These are very reasonable possibilities, right? 814 | 815 | The solution is super simple: We'll make documents based off of the user's `uid` in Cloud Firestore. 816 | 817 | Let's give ourselves some of the infrastructure for this. 818 | 819 | ```js 820 | export const createUserDocument = async (user, additionalData) => { 821 | // If there is no user, let's not do this. 822 | if (!user) return; 823 | 824 | // Get a reference to the location in the Firestore where the user 825 | // document may or may not exist. 826 | const userRef = firestore.doc(`users/${user.uid}`); 827 | 828 | // Go and fetch a document from that location. 829 | const snapshot = await userRef.get(); 830 | 831 | // If there isn't a document for that user. Let's use information 832 | // that we got from either Google or our sign up form. 833 | if (!snapshot.exists) { 834 | const { displayName, email, photoURL } = user; 835 | const createdAt = new Date(); 836 | try { 837 | await userRef.set({ 838 | displayName, 839 | email, 840 | photoURL, 841 | createdAt, 842 | ...additionalData, 843 | }); 844 | } catch (error) { 845 | console.error('Error creating user', console.error); 846 | } 847 | } 848 | 849 | // Get the document and return it, since that's what we're 850 | // likely to want to do next. 851 | return getUserDocument(user.uid); 852 | }; 853 | 854 | export const getUserDocument = async uid => { 855 | if (!uid) return null; 856 | try { 857 | const userDocument = await firestore 858 | .collection('users') 859 | .doc(uid) 860 | .get(); 861 | 862 | return { uid, ...userDocument.data() }; 863 | } catch (error) { 864 | console.error('Error fetching user', error.message); 865 | } 866 | }; 867 | ``` 868 | 869 | We're going to put this two places: 870 | 871 | - `onAuthStateChanged` in order to get our Google Sign Ups 872 | - In `handleSubmit` in `SignUp` because that's where we'll have that custom display name. 873 | 874 | ### Updating Security Rules 875 | 876 | ``` 877 | match /users/{userId} { 878 | allow read; 879 | allow write: if request.auth.uid == userId; 880 | } 881 | ``` 882 | 883 | ## Modern State Management in React with Firebase 884 | 885 | We have a small bug. For our first-time email users, we'll still get null for their display name. 886 | 887 | We could solve all of this by passing everything down from the `Application` component, but I feel like we might be able do a little better. 888 | 889 | We could wrap everything in HOCs, but that might also end us up in a position where we make additional queries to Cloud Firestore. This isn't ideal, but it's probably not the biggest problem in the world. 890 | 891 | We could use Redux or something, but that seems like overkill. 892 | 893 | What if we used React's Context API? 894 | 895 | ### PostsProvider 896 | 897 | ```js 898 | import React, { Component, createContext } from 'react'; 899 | import { firestore } from '../firebase'; 900 | import { collectIdsAndData } from '../utilities'; 901 | 902 | export const PostsContext = createContext(); 903 | 904 | class PostsProvider extends Component { 905 | state = { posts: [] }; 906 | 907 | unsubscribe = null; 908 | 909 | componentDidMount = () => { 910 | this.unsubscribe = firestore.collection('posts').onSnapshot(snapshot => { 911 | const posts = snapshot.docs.map(collectIdsAndData); 912 | this.setState({ posts }); 913 | }); 914 | }; 915 | 916 | componentWillUnmount = () => { 917 | this.unsubscribe(); 918 | }; 919 | 920 | render() { 921 | const { posts } = this.state; 922 | const { children } = this.props; 923 | 924 | return ( 925 | {children} 926 | ); 927 | } 928 | } 929 | 930 | export default PostsProvider; 931 | ``` 932 | 933 | ### Hooking Up the Posts Provider 934 | 935 | In `index.jsx`: 936 | 937 | ```js 938 | import PostsProvider from './contexts/PostsProvider'; 939 | 940 | render( 941 | 942 | 943 | , 944 | document.getElementById('root'), 945 | ); 946 | ``` 947 | 948 | In `Posts.jsx`: 949 | 950 | ```js 951 | import React, { useContext } from 'react'; 952 | import Post from './Post'; 953 | import AddPost from './AddPost'; 954 | import { PostsContext } from '../contexts/PostsProvider'; 955 | 956 | const Posts = () => { 957 | // const posts = useContext(PostsContext); 958 | 959 | return ( 960 |
961 | 962 | 963 | {posts => posts.map(post => )} 964 | 965 |
966 | ); 967 | }; 968 | 969 | export default Posts; 970 | ``` 971 | 972 | ### Using Hooks! 973 | 974 | ```js 975 | import React, { useContext } from 'react'; 976 | import Post from './Post'; 977 | import AddPost from './AddPost'; 978 | import { PostsContext } from '../contexts/PostsProvider'; 979 | 980 | const Posts = () => { 981 | const posts = useContext(PostsContext); 982 | 983 | return ( 984 |
985 | 986 | {posts.map(post => )} 987 |
988 | ); 989 | }; 990 | 991 | export default Posts; 992 | ``` 993 | 994 | ### Exercise: User Provider 995 | 996 | In `UserProvider.jsx`: 997 | 998 | ```js 999 | import React, { Component, createContext } from 'react'; 1000 | import { auth, createUserDocument } from '../firebase'; 1001 | 1002 | export const UserContext = createContext({ user: null }); 1003 | 1004 | class UserProvider extends Component { 1005 | state = { user: null }; 1006 | 1007 | componentDidMount = async () => { 1008 | this.unsubscribeFromAuth = auth.onAuthStateChanged(async user => { 1009 | if (user) { 1010 | const userDocument = await createUserDocument(user); 1011 | return this.setState({ user: userDocument.data() }); 1012 | } 1013 | this.setState({ user: null }); 1014 | }); 1015 | }; 1016 | 1017 | componentWillUnmount = () => { 1018 | this.unsubscribeFromAuth(); 1019 | }; 1020 | 1021 | render() { 1022 | const { children } = this.props; 1023 | const { user } = this.state; 1024 | 1025 | return {children}; 1026 | } 1027 | } 1028 | 1029 | export default UserProvider; 1030 | ``` 1031 | 1032 | In `index.jsx`: 1033 | 1034 | ```js 1035 | import React from 'react'; 1036 | import { render } from 'react-dom'; 1037 | 1038 | import './index.scss'; 1039 | 1040 | import Application from './components/Application'; 1041 | import PostsProvider from './contexts/PostsProvider'; 1042 | import UserProvider from './contexts/UserProvider'; 1043 | 1044 | render( 1045 | 1046 | 1047 | 1048 | 1049 | , 1050 | document.getElementById('root'), 1051 | ); 1052 | ``` 1053 | 1054 | In `UserDashboard.jsx`: 1055 | 1056 | ```js 1057 | import React, { useContext } from 'react' 1058 | import { UserContext } from '../contexts/UserProvider'; 1059 | import SignIn from './SignIn'; 1060 | import CurrentUser from './CurrentUser'; 1061 | 1062 | const UserDashboard = () => { 1063 | const user = useContext(UserContext); 1064 | 1065 | return ( 1066 |
1067 | {user ? : } 1068 |
1069 | ) 1070 | }; 1071 | 1072 | export default UserDashboard; 1073 | ``` 1074 | 1075 | 1076 | ## Cleaning Up the User Interface 1077 | 1078 | Maybe let's stop showing stuff that the user can't do? 1079 | 1080 | In `Posts.jsx`: 1081 | 1082 | ```js 1083 | {user && } 1084 | ``` 1085 | 1086 | In `Post.jsx`: 1087 | 1088 | ```js 1089 | const belongsToCurrentUser = (currentUser, postAuthor) => { 1090 | if (!currentUser) return false; 1091 | return currentUser.uid === postAuthor.uid; 1092 | } 1093 | 1094 | //… 1095 | 1096 | belongsToCurrentUser(currentUser, user) && 1107 | ``` 1108 | 1109 | **Note**: If you punted on the loading state for user states earlier, now is a good time.\ 1110 | 1111 | ## Creating a User Profile Page 1112 | 1113 | We'll make a very simple `UserProfilePage`: 1114 | 1115 | ```js 1116 | class UserPfoile extends Component { 1117 | render() { 1118 | return ( 1119 |
I am the user profile page.
1120 | ); 1121 | } 1122 | } 1123 | ``` 1124 | 1125 | In `index.js`: 1126 | 1127 | ```js 1128 | import { BrowserRouter as Router } from 'react-router-dom'; 1129 | 1130 | render( 1131 | 1132 | 1133 | 1134 | 1135 | 1136 | 1137 | , 1138 | document.getElementById('root'), 1139 | ); 1140 | ``` 1141 | 1142 | In `Application.jsx`: 1143 | 1144 | ```js 1145 | import React, { Component } from 'react'; 1146 | 1147 | import Posts from './Posts'; 1148 | import Authentication from './Authenication'; 1149 | 1150 | import { Switch, Link, Route } from 'react-router-dom'; 1151 | import UserProfile from './UserProfilePage'; 1152 | 1153 | class Application extends Component { 1154 | 1155 | render() { 1156 | return ( 1157 |
1158 |

Think Piece

1159 | 1160 | 1161 | 1162 | 1163 | 1164 |
1165 | ); 1166 | } 1167 | } 1168 | 1169 | export default Application; 1170 | ``` 1171 | 1172 | In `CurrentUser.jsx`: 1173 | 1174 | ```js 1175 |

{displayName}

1176 | ``` 1177 | 1178 | Okay, let's draw the owl with the `UserProfile` page. 1179 | 1180 | ```js 1181 | import React, { Component } from 'react'; 1182 | import { auth, firestore } from '../firebase'; 1183 | 1184 | class UserProfile extends Component { 1185 | state = { displayName: '' }; 1186 | imageInput = null; 1187 | 1188 | get uid() { 1189 | return auth.currentUser.uid; 1190 | } 1191 | 1192 | get userRef() { 1193 | return firestore.collection('users').doc(this.uid); 1194 | } 1195 | 1196 | handleChange = event => { 1197 | const { name, value } = event.target; 1198 | this.setState({ [name]: value }); 1199 | }; 1200 | 1201 | handleSubmit = event => { 1202 | event.preventDefault(); 1203 | 1204 | const { displayName } = this.state; 1205 | 1206 | if (displayName) { 1207 | this.userRef.update(this.state); 1208 | } 1209 | }; 1210 | 1211 | render() { 1212 | const { displayName } = this.state; 1213 | 1214 | return ( 1215 |
1216 |
1217 | 1224 | (this.imageInput = ref)} /> 1225 | 1226 |
1227 |
1228 | ); 1229 | } 1230 | } 1231 | 1232 | export default UserProfile; 1233 | ``` 1234 | 1235 | ## Storage 1236 | 1237 | So, what if the user wants to upload a new profile picture? We should facilitate that, right? 1238 | 1239 | Firebase also includes storage as well. 1240 | 1241 | Let's add storage to `firebase.js`: 1242 | 1243 | ```js 1244 | import 'firebase/storage'; 1245 | ``` 1246 | 1247 | Cool, we'll export that as well. 1248 | 1249 | ```js 1250 | export const storage = firebase.storage(); 1251 | ``` 1252 | 1253 | ### Wiring Up the File Upload 1254 | 1255 | Back in `UserProfile.jsx`: 1256 | 1257 | ```js 1258 | imageInput = null; 1259 | 1260 | get file() { 1261 | return this.imageInput && this.imageInput.files[0]; 1262 | } 1263 | ``` 1264 | 1265 | ```js 1266 | if (this.file) { 1267 | storage 1268 | .ref() 1269 | .child('user-profiles') 1270 | .child(this.uid) 1271 | .child(this.file.name) 1272 | .put(this.file) 1273 | .then(response => response.ref.getDownloadURL()) 1274 | .then(photoURL => this.userRef.update({ photoURL })); 1275 | } 1276 | ``` 1277 | 1278 | ### Setting Security Rules on the Bucket 1279 | 1280 | You can get really excited, but it's going to blow up. 1281 | 1282 | ``` 1283 | service firebase.storage { 1284 | match /b/{bucket}/o { 1285 | match /user-profile/{userId}/{photoURL} { 1286 | allow read, write: if request.auth.uid == userId; 1287 | } 1288 | } 1289 | } 1290 | ``` 1291 | 1292 | ## Working with Sub-collections 1293 | 1294 | Let's create a page for a single post where people can leave comments. 1295 | 1296 | ```js 1297 | import React, { Component } from 'react'; 1298 | 1299 | import { withRouter, Link, Redirect } from 'react-router-dom'; 1300 | import Post from './Post'; 1301 | import Comments from './Comments'; 1302 | import { firestore } from '../firebase'; 1303 | import { collectIdsAndData } from '../utilities'; 1304 | 1305 | class PostPage extends Component { 1306 | state = { post: null, comments: [], loaded: false }; 1307 | 1308 | get postId() { 1309 | return this.props.match.params.id; 1310 | } 1311 | 1312 | get postRef() { 1313 | return firestore.doc(`/posts/${this.postId}`); 1314 | } 1315 | 1316 | get commentsRef() { 1317 | return this.postRef.collection('comments'); 1318 | } 1319 | 1320 | unsubscribeFromPost = []; 1321 | unsubscribeFromComments = []; 1322 | 1323 | componentDidMount = async () => { 1324 | this.unsubscribeFromPost = this.postRef.onSnapshot(snapshot => { 1325 | const post = collectIdsAndData(snapshot); 1326 | this.setState({ post, loaded: true }); 1327 | }); 1328 | 1329 | this.unsubscribeFromComments = this.commentsRef.onSnapshot(snapshot => { 1330 | const comments = snapshot.docs.map(collectIdsAndData); 1331 | this.setState({ comments }); 1332 | }); 1333 | }; 1334 | 1335 | componentWillUnmount = () => { 1336 | this.unsubscribeFromPost(); 1337 | this.unsubscribeFromComments(); 1338 | }; 1339 | 1340 | createComment = (comment, user) => { 1341 | this.commentsRef.add({ 1342 | ...comment, 1343 | user, 1344 | }); 1345 | }; 1346 | 1347 | render() { 1348 | const { post, comments, loaded } = this.state; 1349 | 1350 | if (!loaded) return

Loading…

; 1351 | 1352 | return ( 1353 |
1354 | {post && } 1355 | 1360 |
1361 | ← Back 1362 |
1363 |
1364 | ); 1365 | } 1366 | } 1367 | 1368 | export default withRouter(PostPage); 1369 | ``` 1370 | 1371 | ### Using the Higher Order Component Patter 1372 | 1373 | Now, the comment button doesn't work just yet. We could pass stuff all of the way down, but we've decided that's cross. Let's try another pattern for size. 1374 | 1375 | Remember, `withRouter`? That was pretty cool. Let's maybe try it with our user object. 1376 | 1377 | In `withUser.jsx`: 1378 | 1379 | ```js 1380 | import React from 'react'; 1381 | import { UserContext } from '../contexts/UserProvider'; 1382 | 1383 | function getDisplayName(WrappedComponent) { 1384 | return WrappedComponent.displayName || WrappedComponent.name || 'Component'; 1385 | } 1386 | 1387 | const withUser = Component => { 1388 | 1389 | const WrappedComponent = props => ( 1390 | 1391 | {user => } 1392 | 1393 | ); 1394 | WrappedComponent.displayName = `WithUser(${getDisplayName(WrappedComponent)})`; 1395 | return WrappedComponent; 1396 | }; 1397 | export default withUser; 1398 | ``` 1399 | 1400 | Now, we can use `withRouter` and `withUser` to get everything we need to our components. 1401 | 1402 | ```js 1403 | handleSubmit = event => { 1404 | event.preventDefault(); 1405 | 1406 | 1407 | const { user } = this.props.user; 1408 | const { id: postId } = this.props.match.params; 1409 | 1410 | firestore.collection(`posts/${postId}/comments`).add({ 1411 | ...this.state, user 1412 | }); 1413 | 1414 | this.setState({ content: '' }); 1415 | }; 1416 | ``` 1417 | 1418 | ## Hosting 1419 | 1420 | Make sure you have the latest version of the Firebase CLI tools. 1421 | 1422 | ``` 1423 | firebase install -g firebase-tools 1424 | ``` 1425 | 1426 | (This will also do the trick if you don't have them at all.) 1427 | 1428 | You'll also need to be logged in. 1429 | 1430 | ``` 1431 | firebase login 1432 | ``` 1433 | 1434 | You'll need to initialize your project. 1435 | 1436 | ``` 1437 | firebase init 1438 | ``` 1439 | 1440 | We'll pick the services we want to use and go with the defaults. 1441 | 1442 | **Note**: Notice how it pulled down our security rules for Firestore and Storage. This means we can actually edit this stuff locally, which is pretty cool. 1443 | 1444 | **Note**: You want to make sure that you're "public" directory is `build` and not `public`. 1445 | 1446 | There is one setting where we do *not* want the default option: 1447 | 1448 | > ? Configure as a single-page app (rewrite all urls to /index.html)? *Yes* 1449 | 1450 | We'll modify our npm scripts to run our `build` script followed by `firebase deploy`. 1451 | 1452 | In `package.json`: 1453 | 1454 | ``` 1455 | "deploy": "npm run build && firebase deploy" 1456 | ``` 1457 | 1458 | Cool! Now it should be online. 1459 | 1460 | If you messed up any of the settings, then you should be able to play with them in `firebase.json`. 1461 | 1462 | ### Production Logs and Rolling Back 1463 | 1464 | If we head over to our project console, we can see each deploy that we've done. 1465 | 1466 | The cool thing to notice here that that you can rollback to previous deploys if necessary. 1467 | 1468 | Other than that, there is not a lot to see here. 1469 | 1470 | ## Functions 1471 | 1472 | Make sure you have the latest versions of the helper libraries installed. 1473 | 1474 | ```js 1475 | npm install firebase-functions@latest firebase-admin@latest --save 1476 | ``` 1477 | 1478 | ### Getting Started 1479 | 1480 | Let's start by just uncommenting the example that it's `functions/index.js`. 1481 | 1482 | ```js 1483 | const functions = require('firebase-functions'); 1484 | 1485 | // // Create and Deploy Your First Cloud Functions 1486 | // // https://firebase.google.com/docs/functions/write-firebase-functions 1487 | // 1488 | exports.helloWorld = functions.https.onRequest((request, response) => { 1489 | response.send("Hello from Firebase!"); 1490 | }); 1491 | ``` 1492 | 1493 | Very cool. Let's go ahead and deploy that function and see how it goes. 1494 | 1495 | ``` 1496 | firebase deploy --only functions 1497 | ``` 1498 | 1499 | Okay, let's go visit that on the web. 1500 | 1501 | ``` 1502 | https://us-central1-MY_PROJECT.cloudfunctions.net/helloWorld 1503 | ``` 1504 | 1505 | Neat. Your API endpoint should world. You can `curl` it if you don't believe me. 1506 | 1507 | ### Working with Cloud Firestore 1508 | 1509 | #### Creating a Posts Endpoint 1510 | 1511 | ```js 1512 | const admin = require('firebase-admin'); 1513 | admin.initializeApp(functions.config().firebase); 1514 | 1515 | const firestore = admin.firestore(); 1516 | const settings = { timestampsInSnapshots: true }; 1517 | firestore.settings(settings); 1518 | 1519 | // .. 1520 | 1521 | exports.getAllPosts = functions.https.onRequest(async (request, response) => { 1522 | const snapshot = await admin 1523 | .firestore() 1524 | .collection('posts') 1525 | .get(); 1526 | const posts = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() })); 1527 | 1528 | response.json({ posts }); 1529 | }); 1530 | ``` 1531 | 1532 | This will fail for the stupidest reason. `async` is not supported in Node 6. 1533 | 1534 | You have two options: rewrite this for promises or use the Node 8 engine. 1535 | 1536 | Let's go with the latter. 1537 | 1538 | #### Running Functions Locally 1539 | 1540 | Deploying your functions every time can get tedious. Luckily, we can spin up a server to help us test our functions locally. 1541 | 1542 | #### Listening for Cloud Firestore Triggers 1543 | 1544 | We can also listen for events on documents in Firebase and automatically trigger functions. 1545 | 1546 | Let's try to increment the comment count whenever we find ourselves making a comment. 1547 | 1548 | ```js 1549 | exports.incrementCommentCount = functions.firestore 1550 | .document('posts/{postId}/comments/{commentId}') 1551 | .onCreate(async (snapshot, context) => { 1552 | const { postId } = context.params; 1553 | const postRef = firestore.doc(`posts/${postId}`); 1554 | 1555 | const comments = await postRef.get('comments'); 1556 | return postRef.update({ comments: comments + 1 }); 1557 | }); 1558 | ``` 1559 | 1560 | #### Exercise: Decrementing Comments 1561 | 1562 | Can you implement decrementing the comment count? 1563 | 1564 | (**Note**: We don't have a way to delete comments in the UI. You can either do this in Firebase console—or you can just implement the user interface!) 1565 | 1566 | ```js 1567 | exports.incrementCommentCount = functions.firestore 1568 | .document('posts/{postId}/comments/{commentId}') 1569 | .onCreate(async (snapshot, context) => { 1570 | const { postId } = context.params; 1571 | const postRef = firestore.doc(`posts/${postId}`); 1572 | 1573 | const comments = await postRef.get('comments'); 1574 | return postRef.update({ comments: comments + 1 }); 1575 | }); 1576 | ``` 1577 | 1578 | #### Sanitize Content 1579 | 1580 | ```js 1581 | exports.sanitizeContent = functions.firestore 1582 | .document('posts/{postId}') 1583 | .onWrite(async change => { 1584 | if (!change.after.exists) return; 1585 | 1586 | const { content, sanitized } = change.after.data(); 1587 | 1588 | if (content && !sanitized) { 1589 | return change.after.ref.update({ 1590 | content: content.replace(/CoffeeScript/g, '***'), 1591 | sanitized: true, 1592 | }); 1593 | } 1594 | 1595 | return null; 1596 | }); 1597 | ``` 1598 | 1599 | #### Updating User Information on a Post 1600 | 1601 | ```js 1602 | exports.updateUserInformation = functions.firestore 1603 | .document('users/{userId}') 1604 | .onUpdate(async (snapshot, context) => { 1605 | const { displayName } = snapshot.data(); 1606 | 1607 | const postsRef = firestore 1608 | .collection('posts') 1609 | .where('user.uid', '==', snapshot.id); 1610 | 1611 | return postsRef.get(postSnaps => { 1612 | postSnaps.forEach(doc => { 1613 | doc.ref.update({ 'user.displayName': displayName }); 1614 | }); 1615 | }); 1616 | }); 1617 | ``` 1618 | 1619 | ## Bonus Content 1620 | 1621 | ### Render Prop Pattern 1622 | 1623 | ```js 1624 | import React, { Component } from 'react' 1625 | import { firestore } from '../firebase'; 1626 | import { collectIdsAndData } from '../utilities'; 1627 | 1628 | class PostsForUser extends Component { 1629 | state = { posts: [] }; 1630 | 1631 | unsubscribe = null; 1632 | 1633 | componentDidMount = () => { 1634 | const { uid } = this.props; 1635 | this.unsubscribe = firestore.collection('posts').where('user.uid', '==', uid).orderBy('createdAt', 'desc').onSnapshot(snapshot => { 1636 | const posts = snapshot.docs.map(collectIdsAndData); 1637 | this.setState({ posts }); 1638 | }); 1639 | }; 1640 | 1641 | componentWillUnmount = () => { 1642 | this.unsubscribe(); 1643 | }; 1644 | 1645 | render() { 1646 | return ( 1647 |
1648 | {this.props.children(this.state.posts)} 1649 |
1650 | ) 1651 | } 1652 | } 1653 | 1654 | export default PostsForUser; 1655 | ``` 1656 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "think-piece", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "firebase": "^5.7.0", 7 | "firebase-admin": "^6.4.0", 8 | "firebase-tools": "^6.1.2", 9 | "moment": "^2.22.2", 10 | "react": "^16.7.0-alpha.2", 11 | "react-dom": "^16.7.0-alpha.2", 12 | "react-redux": "^6.0.0", 13 | "react-router-dom": "^4.3.1", 14 | "react-scripts": "^2.1.5", 15 | "redux": "^4.0.1" 16 | }, 17 | "scripts": { 18 | "start": "react-scripts start", 19 | "build": "react-scripts build", 20 | "test": "react-scripts test", 21 | "eject": "react-scripts eject" 22 | }, 23 | "eslintConfig": { 24 | "extends": "react-app" 25 | }, 26 | "browserslist": [ 27 | ">0.2%", 28 | "not dead", 29 | "not ie <= 11", 30 | "not op_mini all" 31 | ], 32 | "devDependencies": { 33 | "node-sass": "^4.11.0" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevekinney/think-piece/00bdc6fa92de61b84a51c86368807561dd24c894/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 22 | Think Piece 23 | 24 | 25 | 28 |
29 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /src/components/AddComment.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | 3 | class AddComment extends Component { 4 | state = { content: '' }; 5 | 6 | handleChange = event => { 7 | const { name, value } = event.target; 8 | this.setState({ [name]: value }); 9 | }; 10 | 11 | handleSubmit = event => { 12 | event.preventDefault(); 13 | 14 | this.setState({ content: '' }); 15 | }; 16 | 17 | render() { 18 | const { content } = this.state; 19 | return ( 20 |
21 | 28 | 29 |
30 | ); 31 | } 32 | } 33 | 34 | export default AddComment; 35 | -------------------------------------------------------------------------------- /src/components/AddPost.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | 3 | class AddPost extends Component { 4 | state = { title: '', content: '' }; 5 | 6 | handleChange = event => { 7 | const { name, value } = event.target; 8 | this.setState({ [name]: value }); 9 | }; 10 | 11 | handleSubmit = event => { 12 | event.preventDefault(); 13 | 14 | const { onCreate } = this.props; 15 | const { title, content } = this.state; 16 | 17 | const post = { 18 | id: Date.now().toString(), 19 | title, 20 | content, 21 | user: { 22 | uid: '1111', 23 | displayName: 'Steve Kinney', 24 | email: 'steve@mailinator.com', 25 | photoURL: 'http://placekitten.com/g/200/200', 26 | }, 27 | favorites: 0, 28 | comments: 0, 29 | createdAt: new Date(), 30 | } 31 | 32 | onCreate(post); 33 | 34 | this.setState({ title: '', content: '' }); 35 | }; 36 | 37 | render() { 38 | const { title, content } = this.state; 39 | return ( 40 |
41 | 48 | 55 | 56 |
57 | ); 58 | } 59 | } 60 | 61 | export default AddPost; 62 | -------------------------------------------------------------------------------- /src/components/Application.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | 3 | import Posts from './Posts'; 4 | 5 | class Application extends Component { 6 | state = { 7 | posts: [ 8 | { 9 | id: '1', 10 | title: 'A Very Hot Take', 11 | content: 12 | 'Lorem, ipsum dolor sit amet consectetur adipisicing elit. Perferendis suscipit repellendus modi unde cumque, fugit in ad necessitatibus eos sed quasi et! Commodi repudiandae tempora ipsum fugiat. Quam, officia excepturi!', 13 | user: { 14 | uid: '123', 15 | displayName: 'Bill Murray', 16 | email: 'billmurray@mailinator.com', 17 | photoURL: 'https://www.fillmurray.com/300/300', 18 | }, 19 | stars: 1, 20 | comments: 47, 21 | }, 22 | { 23 | id: '2', 24 | title: 'The Sauciest of Opinions', 25 | content: 26 | 'Lorem, ipsum dolor sit amet consectetur adipisicing elit. Perferendis suscipit repellendus modi unde cumque, fugit in ad necessitatibus eos sed quasi et! Commodi repudiandae tempora ipsum fugiat. Quam, officia excepturi!', 27 | user: { 28 | uid: '456', 29 | displayName: 'Mill Burray', 30 | email: 'notbillmurray@mailinator.com', 31 | photoURL: 'https://www.fillmurray.com/400/400', 32 | }, 33 | stars: 3, 34 | comments: 0, 35 | }, 36 | ], 37 | }; 38 | 39 | handleCreate = post => { 40 | const { posts } = this.state; 41 | this.setState({ posts: [post, ...posts] }); 42 | }; 43 | 44 | render() { 45 | const { posts } = this.state; 46 | 47 | return ( 48 |
49 |

Think Piece

50 | 51 |
52 | ); 53 | } 54 | } 55 | 56 | export default Application; 57 | -------------------------------------------------------------------------------- /src/components/Authentication.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import CurrentUser from './CurrentUser'; 4 | import SignInAndSignUp from './SignInAndSignUp'; 5 | 6 | const Authentication = ({ user, loading }) => { 7 | if (loading) return null; 8 | 9 | return
{user ? : }
; 10 | }; 11 | 12 | export default Authentication; 13 | -------------------------------------------------------------------------------- /src/components/Comment.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import moment from 'moment'; 4 | 5 | const Comment = ({ content, user, createdAt }) => { 6 | return ( 7 |
8 | {user.displayName} 9 | {content} 10 | {moment(createdAt).calendar()} 11 |
12 | ); 13 | }; 14 | 15 | Comment.defaultProps = { 16 | title: 'An Incredibly Hot Take', 17 | content: 18 | 'Lorem ipsum dolor sit, amet consectetur adipisicing elit. Ducimus est aut dolorem, dolor voluptatem assumenda possimus officia blanditiis iusto porro eaque non ab autem nihil! Alias repudiandae itaque quo provident.', 19 | user: { 20 | displayName: 'Bill Murray', 21 | email: 'billmurray@mailinator.com', 22 | photoURL: 'https://www.fillmurray.com/300/300', 23 | }, 24 | createdAt: new Date(), 25 | }; 26 | 27 | export default Comment; 28 | -------------------------------------------------------------------------------- /src/components/Comments.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Comment from './Comment'; 3 | import AddComment from './AddComment'; 4 | 5 | const Comments = ({ comments, onCreate }) => { 6 | return ( 7 |
8 | 9 | {comments.map(comment => )} 10 |
11 | ) 12 | } 13 | 14 | export default Comments; 15 | -------------------------------------------------------------------------------- /src/components/CurrentUser.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import moment from 'moment'; 4 | 5 | const CurrentUser = ({ displayName, photoURL, email, createdAt, children }) => { 6 | return ( 7 |
8 |
9 | {photoURL && {displayName}} 10 |
11 |

{displayName}

12 |

{email}

13 |

{moment(createdAt).calendar()}

14 |
15 |
16 |
17 |
{children}
18 | 19 |
20 |
21 | ); 22 | }; 23 | 24 | CurrentUser.defaultProps = { 25 | displayName: 'Bill Murray', 26 | email: 'billmurray@mailinator.com', 27 | photoURL: 'https://www.fillmurray.com/300/300', 28 | createdAt: new Date(), 29 | }; 30 | 31 | export default CurrentUser; 32 | -------------------------------------------------------------------------------- /src/components/Post.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import moment from 'moment'; 4 | 5 | const Post = ({ title, content, user, createdAt, stars, comments }) => { 6 | return ( 7 |
8 |
9 |

{title}

10 |
{content}
11 |
12 |
13 |
14 |

15 | 16 | ⭐️ 17 | 18 | {stars} 19 |

20 |

21 | 22 | 🙊 23 | 24 | {comments} 25 |

26 |

Posted by {user.displayName}

27 |

{moment(createdAt).calendar()}

28 |
29 |
30 | 31 | 32 |
33 |
34 |
35 | ); 36 | }; 37 | 38 | Post.defaultProps = { 39 | title: 'An Incredibly Hot Take', 40 | content: 41 | 'Lorem ipsum dolor sit, amet consectetur adipisicing elit. Ducimus est aut dolorem, dolor voluptatem assumenda possimus officia blanditiis iusto porro eaque non ab autem nihil! Alias repudiandae itaque quo provident.', 42 | user: { 43 | id: '123', 44 | displayName: 'Bill Murray', 45 | email: 'billmurray@mailinator.com', 46 | photoURL: 'https://www.fillmurray.com/300/300', 47 | }, 48 | createdAt: new Date(), 49 | stars: 0, 50 | comments: 0, 51 | }; 52 | 53 | export default Post; 54 | -------------------------------------------------------------------------------- /src/components/Posts.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Post from './Post'; 3 | import AddPost from './AddPost'; 4 | 5 | const Posts = ({ posts, onCreate }) => { 6 | return ( 7 |
8 | 9 | {posts.map(post => )} 10 |
11 | ) 12 | } 13 | 14 | export default Posts; 15 | -------------------------------------------------------------------------------- /src/components/SignIn.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | 3 | class SignIn extends Component { 4 | state = { email: '', password: '' }; 5 | 6 | handleChange = event => { 7 | const { name, value } = event.target; 8 | 9 | this.setState({ [name]: value }); 10 | }; 11 | 12 | handleSubmit = event => { 13 | event.preventDefault(); 14 | 15 | this.setState({ email: '', password: '' }); 16 | }; 17 | 18 | render() { 19 | const { email, password } = this.state; 20 | 21 | return ( 22 |
23 |

Sign In

24 | 31 | 38 | 39 | 40 |
41 | ); 42 | } 43 | } 44 | 45 | export default SignIn; 46 | -------------------------------------------------------------------------------- /src/components/SignInAndSignUp.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import SignIn from './SignIn'; 4 | import SignUp from './SignUp'; 5 | 6 | const SignInAndSignUp = () => ( 7 |
8 | 9 | 10 |
11 | ); 12 | 13 | export default SignInAndSignUp; 14 | -------------------------------------------------------------------------------- /src/components/SignUp.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | 3 | class SignUp extends Component { 4 | state = { displayName: '', email: '', password: '' }; 5 | 6 | handleChange = event => { 7 | const { name, value } = event.target; 8 | 9 | this.setState({ [name]: value }); 10 | }; 11 | 12 | handleSubmit = event => { 13 | event.preventDefault(); 14 | 15 | this.setState({ displayName: '', email: '', password: '' }); 16 | }; 17 | 18 | render() { 19 | const { displayName, email, password } = this.state; 20 | 21 | return ( 22 |
23 |

Sign Up

24 | 31 | 38 | 45 | 46 |
47 | ); 48 | } 49 | } 50 | 51 | export default SignUp; 52 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from 'react-dom'; 3 | 4 | import './index.scss'; 5 | 6 | import Application from './components/Application'; 7 | 8 | render(, document.getElementById('root')); 9 | -------------------------------------------------------------------------------- /src/index.scss: -------------------------------------------------------------------------------- 1 | $input-color: #7dbbc3; 2 | $button-color: #f4b9b2; 3 | $info-color: #daedbd; 4 | $comment-highlight-color: #de6b48; 5 | $danger-color: #de6b48; 6 | 7 | @mixin button($color) { 8 | background-color: $color; 9 | border: 1px solid darken($color, 10); 10 | &:hover { 11 | background-color: lighten($color, 10); 12 | } 13 | &:active { 14 | background-color: lighten($color, 20); 15 | } 16 | } 17 | 18 | html, 19 | *, 20 | *:before, 21 | *:after { 22 | box-sizing: border-box; 23 | color: #2d3436; 24 | } 25 | 26 | html, 27 | body, 28 | input { 29 | font: menu; 30 | } 31 | 32 | input, 33 | button { 34 | display: block; 35 | width: 100%; 36 | &:not(:last-child) { 37 | margin-bottom: 5px; 38 | } 39 | &:focus { 40 | outline: none; 41 | } 42 | } 43 | 44 | input[type='text'], 45 | input[type='email'], 46 | input[type='password'] { 47 | border: none; 48 | border-bottom: 1px solid $input-color; 49 | font-size: 1.5em; 50 | padding: 0.5em; 51 | } 52 | 53 | button, 54 | input[type='submit'], 55 | .button { 56 | display: block; 57 | width: 100%; 58 | font-size: 1.1em; 59 | padding: 0.5em; 60 | @include button($button-color); 61 | &.star, &.create, &.update { 62 | @include button($input-color); 63 | } 64 | &.delete { 65 | @include button($danger-color); 66 | } 67 | } 68 | 69 | input[type="file"] { 70 | margin: 1em 0; 71 | padding: 1em 0; 72 | } 73 | 74 | .Application { 75 | max-width: 600px; 76 | margin: auto; 77 | } 78 | 79 | %authentication { 80 | border: 1px solid $input-color; 81 | padding: 1em; 82 | margin-bottom: 1em; 83 | h2 { 84 | margin: 0.5em 0.5em; 85 | padding: 0.5em; 86 | border-left: 5px solid $info-color; 87 | } 88 | } 89 | 90 | .SignIn { 91 | @extend %authentication; 92 | } 93 | 94 | .SignUp { 95 | @extend %authentication; 96 | } 97 | 98 | .CurrentUser { 99 | @extend %authentication; 100 | .CurrentUser--profile { 101 | display: flex; 102 | margin-bottom: 0.5em; 103 | } 104 | .CurrentUser--information { 105 | flex: 4; 106 | } 107 | img { 108 | flex: 1; 109 | margin-right: 1em; 110 | max-height: 150px; 111 | } 112 | h2 { 113 | margin-bottom: 1em; 114 | } 115 | .created-at:before { 116 | content: 'Joined'; 117 | color: $comment-highlight-color; 118 | font-weight: bold; 119 | margin: 0.5em 0.5em; 120 | } 121 | .email:before { 122 | content: 'Email'; 123 | color: $comment-highlight-color; 124 | font-weight: bold; 125 | margin: 0.5em 0.5em; 126 | } 127 | } 128 | 129 | .AddPost, 130 | .AddComment { 131 | margin-bottom: 1em; 132 | } 133 | 134 | .AddComment { 135 | display: flex; 136 | input { 137 | margin: 0; 138 | padding: 0.5em; 139 | } 140 | input[type="text"] { 141 | flex: 3; 142 | } 143 | input[type="submit"] { 144 | flex: 1; 145 | } 146 | } 147 | 148 | .Post { 149 | border: 1px solid $input-color; 150 | margin-bottom: 1em; 151 | } 152 | 153 | .Post--content { 154 | padding: 1em; 155 | h3 { 156 | margin-top: 0; 157 | } 158 | } 159 | 160 | .Post--meta { 161 | padding: 0.5em; 162 | background-color: $info-color; 163 | display: flex; 164 | justify-content: space-between; 165 | align-items: center; 166 | p { 167 | display: inline-block; 168 | margin: 1em; 169 | } 170 | button { 171 | display: inline-block; 172 | width: auto; 173 | &:not(:last-child) { 174 | margin-right: 5px; 175 | margin-bottom: 0; 176 | } 177 | } 178 | } 179 | 180 | .Comment { 181 | margin: 1em 0; 182 | } 183 | 184 | .Comment--author { 185 | color: $comment-highlight-color; 186 | font-weight: bold; 187 | &:after { 188 | content: ': '; 189 | } 190 | } 191 | 192 | .Comment--timestamp { 193 | color: darken($info-color, 40); 194 | &:before { 195 | content: ' '; 196 | } 197 | } 198 | --------------------------------------------------------------------------------