├── .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 |
9 |
10 |
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 |    |   
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 |
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 |
14 |
15 |
16 |
21 | Top
22 |
23 |
24 |
25 |
29 | New
30 |
31 |
32 |
33 |
38 | {theme === 'light' ? '🔦' : '💡'}
39 |
40 |
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 |
51 |
57 |
58 | }
59 | {loadingComments === true
60 | ? loadingPost === false &&
61 | :
62 | {comments.map((comment) =>
63 |
67 | )}
68 | }
69 |
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 |
10 | {({ theme }) => (
11 |
12 | by {by}
13 | on {formatDate(time)}
14 | {typeof descendants === 'number' && (
15 |
16 | with {descendants} comments
17 |
18 | )}
19 |
20 | )}
21 |
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
44 | }
45 |
46 | if (error) {
47 | return {error}
48 | }
49 |
50 | return
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 |
10 | This user hasn't posted yet
11 |
12 | )
13 | }
14 |
15 | return (
16 |
17 | {posts.map((post) => {
18 | return (
19 |
20 |
21 |
27 |
28 | )
29 | })}
30 |
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 | ? {title}
8 | : {title}
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 {error}
41 | }
42 |
43 | return (
44 |
45 | {loadingUser === true
46 | ?
47 | :
48 | {user.id}
49 |
50 | joined {formatDate(user.created)}
51 | has {user.karma.toLocaleString()} karma
52 |
53 |
54 | }
55 | {loadingPosts === true
56 | ? loadingUser === false &&
57 | :
58 | Posts
59 |
60 | }
61 |
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 |
2 |
3 |
4 | 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 |
29 |
30 |
}>
31 |
32 | }
36 | />
37 | }
40 | />
41 |
42 |
43 | 404 } />
44 |
45 |
46 |
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 | }
--------------------------------------------------------------------------------