├── .gitignore ├── README.md ├── _redirects ├── app ├── components │ ├── Comment.js │ ├── Loading.js │ ├── Nav.js │ ├── Post.js │ ├── PostMetaInfo.js │ ├── Posts.js │ ├── PostsList.js │ ├── Title.js │ └── User.js ├── contexts │ └── theme.js ├── index.css ├── index.html ├── index.js └── utils │ ├── api.js │ └── helpers.js ├── package-lock.json ├── package.json └── webpack.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | UI.dev Logo 6 | 7 |
8 |

9 | 10 |

React Hooks Course Curriculum - Hacker News Clone

11 | 12 | ### Info 13 | 14 | This is the repository for UI.dev's "React Hooks" course curriculum project. 15 | 16 | For more information on the course, visit __[ui.dev/react-hooks/](https://ui.dev/react-hooks/)__. 17 | 18 | ### Assignment 19 | 20 | Clone this repo and refactor it to use React Hooks. The starter code is located on the "master" branch. 21 | 22 | ### Project 23 | 24 | This is a (soft) "Hacker News" clone. You can view the final project at __[hn.ui.dev/](http://hn.ui.dev/)__. 25 | 26 | ### Solution 27 | 28 | If you get stuck, you can view my solution by checking out the `solution` branch. 29 | 30 | ### Project Preview 31 | 32 | Light Mode | Dark Mode 33 | :-------------------------:|:-------------------------: 34 | ![](https://user-images.githubusercontent.com/2933430/55523754-c1775200-5647-11e9-9394-387cd49a012c.png) ![](https://user-images.githubusercontent.com/2933430/55523752-c0debb80-5647-11e9-91e0-cd2dd38b3255.png) ![](https://user-images.githubusercontent.com/2933430/55523749-c0debb80-5647-11e9-9575-80262d951938.png) | ![](https://user-images.githubusercontent.com/2933430/55523751-c0debb80-5647-11e9-865e-fc829b2566f8.png) ![](https://user-images.githubusercontent.com/2933430/55523753-c1775200-5647-11e9-8230-db5ea02e7333.png) ![](https://user-images.githubusercontent.com/2933430/55523750-c0debb80-5647-11e9-835b-79530775d1b9.png) 35 | 36 | ### [Tyler McGinnis](https://twitter.com/tylermcginnis) 37 | -------------------------------------------------------------------------------- /_redirects: -------------------------------------------------------------------------------- 1 | /* /index.html 200 -------------------------------------------------------------------------------- /app/components/Comment.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import PostMetaInfo from './PostMetaInfo' 4 | 5 | export default function Comment ({ comment }) { 6 | return ( 7 |
8 | 14 |

15 |

16 | ) 17 | } -------------------------------------------------------------------------------- /app/components/Loading.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | const styles = { 5 | content: { 6 | fontSize: '35px', 7 | position: 'absolute', 8 | left: '0', 9 | right: '0', 10 | marginTop: '20px', 11 | textAlign: 'center', 12 | } 13 | } 14 | 15 | export default class Loading extends React.Component { 16 | state = { content: this.props.text } 17 | componentDidMount () { 18 | const { speed, text } = this.props 19 | 20 | this.interval = window.setInterval(() => { 21 | this.state.content === text + '...' 22 | ? this.setState({ content: text }) 23 | : this.setState(({ content }) => ({ content: content + '.' })) 24 | }, speed) 25 | } 26 | componentWillUnmount () { 27 | window.clearInterval(this.interval) 28 | } 29 | render() { 30 | return ( 31 |

32 | {this.state.content} 33 |

34 | ) 35 | } 36 | } 37 | 38 | Loading.propTypes = { 39 | text: PropTypes.string.isRequired, 40 | speed: PropTypes.number.isRequired, 41 | } 42 | 43 | Loading.defaultProps = { 44 | text: 'Loading', 45 | speed: 300 46 | } 47 | -------------------------------------------------------------------------------- /app/components/Nav.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { ThemeConsumer } from '../contexts/theme' 3 | import { NavLink } from 'react-router-dom' 4 | 5 | const activeStyle = { 6 | color: 'rgb(187, 46, 31)' 7 | } 8 | 9 | export default function Nav () { 10 | return ( 11 | 12 | {({ theme, toggleTheme }) => ( 13 | 41 | )} 42 | 43 | ) 44 | } -------------------------------------------------------------------------------- /app/components/Post.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import queryString from 'query-string' 3 | import { fetchItem, fetchPosts, fetchComments } from '../utils/api' 4 | import Loading from './Loading' 5 | import PostMetaInfo from './PostMetaInfo' 6 | import Title from './Title' 7 | import Comment from './Comment' 8 | 9 | export default class Post extends React.Component { 10 | state = { 11 | post: null, 12 | loadingPost: true, 13 | comments: null, 14 | loadingComments: true, 15 | error: null, 16 | } 17 | componentDidMount() { 18 | const { id } = queryString.parse(this.props.location.search) 19 | 20 | fetchItem(id) 21 | .then((post) => { 22 | this.setState({ post, loadingPost: false }) 23 | 24 | return fetchComments(post.kids || []) 25 | }) 26 | .then((comments) => this.setState({ 27 | comments, 28 | loadingComments: false 29 | })) 30 | .catch(({ message }) => this.setState({ 31 | error: message, 32 | loadingPost: false, 33 | loadingComments: false 34 | })) 35 | } 36 | render() { 37 | const { post, loadingPost, comments, loadingComments, error } = this.state 38 | 39 | if (error) { 40 | return

{error}

41 | } 42 | 43 | return ( 44 | 45 | {loadingPost === true 46 | ? 47 | : 48 |

49 | 50 | </h1> 51 | <PostMetaInfo 52 | by={post.by} 53 | time={post.time} 54 | id={post.id} 55 | descendants={post.descendants} 56 | /> 57 | <p dangerouslySetInnerHTML={{__html: post.text}} /> 58 | </React.Fragment>} 59 | {loadingComments === true 60 | ? loadingPost === false && <Loading text='Fetching comments' /> 61 | : <React.Fragment> 62 | {comments.map((comment) => 63 | <Comment 64 | key={comment.id} 65 | comment={comment} 66 | /> 67 | )} 68 | </React.Fragment>} 69 | </React.Fragment> 70 | ) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /app/components/PostMetaInfo.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Link } from 'react-router-dom' 3 | import PropTypes from 'prop-types' 4 | import { formatDate } from '../utils/helpers' 5 | import { ThemeConsumer } from '../contexts/theme' 6 | 7 | export default function PostMetaInfo ({ by, time, id, descendants }) { 8 | return ( 9 | <ThemeConsumer> 10 | {({ theme }) => ( 11 | <div className={`meta-info-${theme}`}> 12 | <span>by <Link to={`/user?id=${by}`}>{by}</Link></span> 13 | <span>on {formatDate(time)}</span> 14 | {typeof descendants === 'number' && ( 15 | <span> 16 | with <Link to={`/post?id=${id}`}>{descendants}</Link> comments 17 | </span> 18 | )} 19 | </div> 20 | )} 21 | </ThemeConsumer> 22 | ) 23 | } 24 | 25 | PostMetaInfo.propTypes = { 26 | by: PropTypes.string.isRequired, 27 | time: PropTypes.number.isRequired, 28 | id: PropTypes.number.isRequired, 29 | descendants: PropTypes.number, 30 | } -------------------------------------------------------------------------------- /app/components/Posts.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { fetchMainPosts } from '../utils/api' 4 | import Loading from './Loading' 5 | import PostsList from './PostsList' 6 | 7 | export default class Posts extends React.Component { 8 | state = { 9 | posts: null, 10 | error: null, 11 | loading: true, 12 | } 13 | componentDidMount() { 14 | this.handleFetch() 15 | } 16 | componentDidUpdate(prevProps) { 17 | if (prevProps.type !== this.props.type) { 18 | this.handleFetch() 19 | } 20 | } 21 | handleFetch () { 22 | this.setState({ 23 | posts: null, 24 | error: null, 25 | loading: true 26 | }) 27 | 28 | fetchMainPosts(this.props.type) 29 | .then((posts) => this.setState({ 30 | posts, 31 | loading: false, 32 | error: null 33 | })) 34 | .catch(({ message }) => this.setState({ 35 | error: message, 36 | loading: false 37 | })) 38 | } 39 | render() { 40 | const { posts, error, loading } = this.state 41 | 42 | if (loading === true) { 43 | return <Loading /> 44 | } 45 | 46 | if (error) { 47 | return <p className='center-text error'>{error}</p> 48 | } 49 | 50 | return <PostsList posts={posts} /> 51 | } 52 | } 53 | 54 | Posts.propTypes = { 55 | type: PropTypes.oneOf(['top', 'new']) 56 | } -------------------------------------------------------------------------------- /app/components/PostsList.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import PostMetaInfo from './PostMetaInfo' 4 | import Title from './Title' 5 | 6 | export default function PostsList ({ posts }) { 7 | if (posts.length === 0) { 8 | return ( 9 | <p className='center-text'> 10 | This user hasn't posted yet 11 | </p> 12 | ) 13 | } 14 | 15 | return ( 16 | <ul> 17 | {posts.map((post) => { 18 | return ( 19 | <li key={post.id} className='post'> 20 | <Title url={post.url} title={post.title} id={post.id} /> 21 | <PostMetaInfo 22 | by={post.by} 23 | time={post.time} 24 | id={post.id} 25 | descendants={post.descendants} 26 | /> 27 | </li> 28 | ) 29 | })} 30 | </ul> 31 | ) 32 | } 33 | 34 | PostsList.propTypes = { 35 | posts: PropTypes.array.isRequired 36 | } -------------------------------------------------------------------------------- /app/components/Title.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { Link } from 'react-router-dom' 4 | 5 | export default function Title ({ url, title, id }) { 6 | return url 7 | ? <a className='link' href={url}>{title}</a> 8 | : <Link className='link' to={`/post?id=${id}`}>{title}</Link> 9 | } 10 | 11 | Title.propTypes = { 12 | url: PropTypes.string, 13 | title: PropTypes.string.isRequired, 14 | id: PropTypes.number.isRequired 15 | } -------------------------------------------------------------------------------- /app/components/User.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import queryString from 'query-string' 3 | import { fetchUser, fetchPosts } from '../utils/api' 4 | import Loading from './Loading' 5 | import { formatDate } from '../utils/helpers' 6 | import PostsList from './PostsList' 7 | 8 | export default class User extends React.Component { 9 | state = { 10 | user: null, 11 | loadingUser: true, 12 | posts: null, 13 | loadingPosts: true, 14 | error: null, 15 | } 16 | componentDidMount() { 17 | const { id } = queryString.parse(this.props.location.search) 18 | 19 | fetchUser(id) 20 | .then((user) => { 21 | this.setState({ user, loadingUser: false}) 22 | 23 | return fetchPosts(user.submitted.slice(0, 30)) 24 | }) 25 | .then((posts) => this.setState({ 26 | posts, 27 | loadingPosts: false, 28 | error: null 29 | })) 30 | .catch(({ message }) => this.setState({ 31 | error: message, 32 | loadingUser: false, 33 | loadingPosts: false 34 | })) 35 | } 36 | render() { 37 | const { user, posts, loadingUser, loadingPosts, error } = this.state 38 | 39 | if (error) { 40 | return <p className='center-text error'>{error}</p> 41 | } 42 | 43 | return ( 44 | <React.Fragment> 45 | {loadingUser === true 46 | ? <Loading text='Fetching User' /> 47 | : <React.Fragment> 48 | <h1 className='header'>{user.id}</h1> 49 | <div className='meta-info-light'> 50 | <span>joined <b>{formatDate(user.created)}</b></span> 51 | <span>has <b>{user.karma.toLocaleString()}</b> karma</span> 52 | </div> 53 | <p dangerouslySetInnerHTML={{__html: user.about}} /> 54 | </React.Fragment>} 55 | {loadingPosts === true 56 | ? loadingUser === false && <Loading text='Fetching posts'/> 57 | : <React.Fragment> 58 | <h2>Posts</h2> 59 | <PostsList posts={posts} /> 60 | </React.Fragment>} 61 | </React.Fragment> 62 | ) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /app/contexts/theme.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const { Consumer, Provider } = React.createContext() 4 | 5 | export const ThemeConsumer = Consumer 6 | export const ThemeProvider = Provider -------------------------------------------------------------------------------- /app/index.css: -------------------------------------------------------------------------------- 1 | html, body, #app { 2 | margin: 0; 3 | height: 100%; 4 | width: 100%; 5 | } 6 | 7 | body { 8 | font-family: -apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen-Sans,Ubuntu,Cantarell,Helvetica Neue,sans-serif; 9 | } 10 | 11 | ul { 12 | padding: 0; 13 | } 14 | 15 | li { 16 | list-style-type: none; 17 | } 18 | 19 | .container { 20 | max-width: 1200px; 21 | margin: 0 auto; 22 | padding: 50px; 23 | } 24 | 25 | .header { 26 | margin-bottom: 5px; 27 | } 28 | 29 | .dark { 30 | color: #DADADA; 31 | background: #1c2022; 32 | min-height: 100%; 33 | } 34 | 35 | .dark a { 36 | color: rgb(203, 203, 203); 37 | } 38 | 39 | .link { 40 | color: rgb(187, 46, 31); 41 | text-decoration: none; 42 | font-weight: bold; 43 | } 44 | 45 | .row { 46 | display: flex; 47 | flex-direction: row; 48 | } 49 | 50 | .space-between { 51 | justify-content: space-between; 52 | } 53 | 54 | .nav-link { 55 | font-size: 18px; 56 | font-weight: bold; 57 | text-decoration: none; 58 | color: inherit; 59 | } 60 | 61 | .nav li { 62 | margin-right: 10px; 63 | } 64 | 65 | .btn-clear { 66 | border: none; 67 | background: transparent; 68 | } 69 | 70 | .center-text { 71 | text-align: center; 72 | } 73 | 74 | .post { 75 | margin: 20px 0; 76 | } 77 | 78 | .meta-info-light { 79 | margin-top: 5px; 80 | color: gray; 81 | } 82 | 83 | .meta-info-light a { 84 | color: black; 85 | } 86 | 87 | .meta-info-light span { 88 | margin: 10px 0; 89 | margin-right: 6px; 90 | } 91 | 92 | .meta-info-dark { 93 | margin-top: 5px; 94 | color: gray; 95 | } 96 | 97 | .meta-info-dark a { 98 | color: #bebebe; 99 | } 100 | 101 | .meta-info-dark span { 102 | margin: 10px 0; 103 | margin-right: 6px; 104 | } 105 | 106 | .comment { 107 | background: rgba(128, 128, 128, 0.1411764705882353); 108 | padding: 10px; 109 | margin: 10px 0; 110 | border-radius: 5px; 111 | } -------------------------------------------------------------------------------- /app/index.html: -------------------------------------------------------------------------------- 1 | <!DOCTYPE html> 2 | <html> 3 | <head> 4 | <title>Hacker News 5 | 10 | 11 | 12 |
13 | 14 | -------------------------------------------------------------------------------- /app/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import './index.css' 4 | import { BrowserRouter as Router, Route, Switch } from 'react-router-dom' 5 | import { ThemeProvider } from './contexts/theme' 6 | import Loading from './components/Loading' 7 | import Nav from './components/Nav' 8 | 9 | const Posts = React.lazy(() => import('./components/Posts')) 10 | const Post = React.lazy(() => import('./components/Post')) 11 | const User = React.lazy(() => import('./components/User')) 12 | 13 | class App extends React.Component { 14 | state = { 15 | theme: 'light', 16 | toggleTheme: () => { 17 | this.setState(({ theme }) => ({ 18 | theme: theme === 'light' ? 'dark' : 'light' 19 | })) 20 | } 21 | } 22 | render() { 23 | return ( 24 | 25 | 26 |
27 |
28 |
47 |
48 |
49 |
50 | ) 51 | } 52 | } 53 | 54 | ReactDOM.render( 55 | , 56 | document.getElementById('app') 57 | ) -------------------------------------------------------------------------------- /app/utils/api.js: -------------------------------------------------------------------------------- 1 | const api = `https://hacker-news.firebaseio.com/v0` 2 | const json = '.json?print=pretty' 3 | 4 | function removeDead (posts) { 5 | return posts.filter(Boolean).filter(({ dead }) => dead !== true) 6 | } 7 | 8 | function removeDeleted (posts) { 9 | return posts.filter(({ deleted }) => deleted !== true) 10 | } 11 | 12 | function onlyComments (posts) { 13 | return posts.filter(({ type }) => type === 'comment') 14 | } 15 | 16 | function onlyPosts (posts) { 17 | return posts.filter(({ type }) => type === 'story') 18 | } 19 | 20 | export function fetchItem (id) { 21 | return fetch(`${api}/item/${id}${json}`) 22 | .then((res) => res.json()) 23 | } 24 | 25 | export function fetchComments (ids) { 26 | return Promise.all(ids.map(fetchItem)) 27 | .then((comments) => removeDeleted(onlyComments(removeDead(comments)))) 28 | } 29 | 30 | export function fetchMainPosts (type) { 31 | return fetch(`${api}/${type}stories${json}`) 32 | .then((res) => res.json()) 33 | .then((ids) => { 34 | if (!ids) { 35 | throw new Error(`There was an error fetching the ${type} posts.`) 36 | } 37 | 38 | return ids.slice(0, 50) 39 | }) 40 | .then((ids) => Promise.all(ids.map(fetchItem))) 41 | .then((posts) => removeDeleted(onlyPosts(removeDead(posts)))) 42 | } 43 | 44 | export function fetchUser (id) { 45 | return fetch(`${api}/user/${id}${json}`) 46 | .then((res) => res.json()) 47 | } 48 | 49 | export function fetchPosts (ids) { 50 | return Promise.all(ids.map(fetchItem)) 51 | .then((posts) => removeDeleted(onlyPosts(removeDead(posts)))) 52 | } -------------------------------------------------------------------------------- /app/utils/helpers.js: -------------------------------------------------------------------------------- 1 | export function formatDate (timestamp) { 2 | return new Date(timestamp * 1000) 3 | .toLocaleDateString("en-US", { 4 | hour: 'numeric' , 5 | minute: 'numeric' 6 | }) 7 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-hooks-course-curriculum", 3 | "version": "1.0.0", 4 | "description": "Curriculum for TylerMcGinnis.com's React Hooks course.", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "webpack-dev-server", 8 | "build": "NODE_ENV='production' webpack", 9 | "build-for-windows": "SET NODE_ENV='production' && webpack" 10 | }, 11 | "babel": { 12 | "presets": [ 13 | "@babel/preset-env", 14 | "@babel/preset-react" 15 | ], 16 | "plugins": [ 17 | "@babel/plugin-proposal-class-properties", 18 | "syntax-dynamic-import" 19 | ] 20 | }, 21 | "keywords": [], 22 | "author": "", 23 | "license": "ISC", 24 | "dependencies": { 25 | "prop-types": "^15.7.2", 26 | "query-string": "^6.8.1", 27 | "react": "^16.8.6", 28 | "react-dom": "^16.8.6", 29 | "react-router-dom": "^5.0.1" 30 | }, 31 | "devDependencies": { 32 | "@babel/core": "^7.5.5", 33 | "@babel/plugin-proposal-class-properties": "^7.5.5", 34 | "@babel/preset-env": "^7.5.5", 35 | "@babel/preset-react": "^7.0.0", 36 | "babel-loader": "^8.0.6", 37 | "babel-plugin-syntax-dynamic-import": "^6.18.0", 38 | "copy-webpack-plugin": "^5.0.3", 39 | "css-loader": "^3.1.0", 40 | "html-webpack-plugin": "^3.2.0", 41 | "style-loader": "^0.23.1", 42 | "webpack": "^4.36.1", 43 | "webpack-cli": "^3.3.6", 44 | "webpack-dev-server": "^3.7.2" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const HtmlWebpackPlugin = require('html-webpack-plugin') 3 | const CopyPlugin = require('copy-webpack-plugin') 4 | 5 | module.exports = { 6 | entry: './app/index.js', 7 | output: { 8 | path: path.resolve(__dirname, 'dist'), 9 | filename: 'index_bundle.js', 10 | publicPath: '/' 11 | }, 12 | module: { 13 | rules: [ 14 | { test: /\.(js)$/, use: 'babel-loader' }, 15 | { test: /\.css$/, use: [ 'style-loader', 'css-loader' ]} 16 | ] 17 | }, 18 | mode: process.env.NODE_ENV === 'production' ? 'production' : 'development', 19 | plugins: [ 20 | new HtmlWebpackPlugin({ 21 | template: 'app/index.html' 22 | }), 23 | new CopyPlugin([ 24 | { from : '_redirects' } 25 | ]) 26 | ], 27 | devServer: { 28 | historyApiFallback: true 29 | } 30 | } --------------------------------------------------------------------------------