Skip to content

Commit b28b09c

Browse files
committedJul 22, 2019
Starter code
0 parents  commit b28b09c

21 files changed

+8287
-0
lines changed
 

‎.gitignore

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
node_modules
2+
.DS_Store
3+
dist

‎README.md

+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<h1 align="center">
2+
<a href="https://tylermcginnis.com"><img src="https://tylermcginnis.com/tylermcginnis_glasses-300.png" alt="TylerMcGinnis.com Logo" width="300"></a>
3+
<br>
4+
</h1>
5+
6+
<h3 align="center">React Hooks Course Curriculum - <a href="https://tm.dev/react-course-curriculum/">Hacker News Clone</a></h3>
7+
8+
### Info
9+
10+
This is the repository for TylerMcGinnis.com's "React Hooks" course curriculum project.
11+
12+
For more information on the course, visit __[tm.dev/courses/react-hooks](https://tm.dev/courses/react-hooks/)__.
13+
14+
### Assignment
15+
16+
Clone this repo and refactor it to use React Hooks. The starter code is located on the "master" branch.
17+
18+
### Project
19+
20+
This is a (soft) "Hacker News" clone. You can view the final project at __[tm.dev/react-course-curriculum](https://tm.dev/react-course-curriculum/)__.
21+
22+
### Solution
23+
24+
If you get stuck, you can view my solution by checking out the `solution` branch.
25+
26+
### Project Preview
27+
28+
Light Mode | Dark Mode
29+
:-------------------------:|:-------------------------:
30+
![](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)
31+
32+
### [Tyler McGinnis](https://twitter.com/tylermcginnis)

‎_redirects

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/* /index.html 200

‎app/components/Comment.js

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
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+
<div className='comment'>
8+
<PostMetaInfo
9+
comment={true}
10+
by={comment.by}
11+
time={comment.time}
12+
id={comment.id}
13+
/>
14+
<p dangerouslySetInnerHTML={{__html: comment.text}} />
15+
</div>
16+
)
17+
}

‎app/components/Loading.js

+46
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
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+
<p style={styles.content}>
32+
{this.state.content}
33+
</p>
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+
}

‎app/components/Nav.js

+44
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
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+
<ThemeConsumer>
12+
{({ theme, toggleTheme }) => (
13+
<nav className='row space-between'>
14+
<ul className='row nav'>
15+
<li>
16+
<NavLink
17+
to='/'
18+
exact
19+
activeStyle={activeStyle}
20+
className='nav-link'>
21+
Top
22+
</NavLink>
23+
</li>
24+
<li>
25+
<NavLink
26+
to='/new'
27+
activeStyle={activeStyle}
28+
className='nav-link'>
29+
New
30+
</NavLink>
31+
</li>
32+
</ul>
33+
<button
34+
style={{fontSize: 30}}
35+
className='btn-clear'
36+
onClick={toggleTheme}
37+
>
38+
{theme === 'light' ? '🔦' : '💡'}
39+
</button>
40+
</nav>
41+
)}
42+
</ThemeConsumer>
43+
)
44+
}

‎app/components/Post.js

+72
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
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 <p className='center-text error'>{error}</p>
41+
}
42+
43+
return (
44+
<React.Fragment>
45+
{loadingPost === true
46+
? <Loading text='Fetching post' />
47+
: <React.Fragment>
48+
<h1 className='header'>
49+
<Title url={post.url} title={post.title} id={post.id} />
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+
{this.state.comments.map((comment) =>
63+
<Comment
64+
key={comment.id}
65+
comment={comment}
66+
/>
67+
)}
68+
</React.Fragment>}
69+
</React.Fragment>
70+
)
71+
}
72+
}

‎app/components/PostMetaInfo.js

+30
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
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

+56
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
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

+36
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
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

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
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

+64
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
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 Post 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+
}

‎app/contexts/theme.js

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
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

+111
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
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

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<title>Hacker News</title>
5+
<link
6+
rel="icon"
7+
type="image/png"
8+
sizes="32x32"
9+
href="https://tylermcginnis.com/images/favicon/favicon-32x32.png">
10+
</head>
11+
<body>
12+
<div id="app"></div>
13+
</body>
14+
</html>

‎app/index.js

+57
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
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+
<Router>
25+
<ThemeProvider value={this.state}>
26+
<div className={this.state.theme}>
27+
<div className='container'>
28+
<Nav />
29+
30+
<React.Suspense fallback={<Loading />}>
31+
<Switch>
32+
<Route
33+
exact
34+
path='/'
35+
render={() => <Posts type='top' />}
36+
/>
37+
<Route
38+
path='/new'
39+
render={() => <Posts type='new' />}
40+
/>
41+
<Route path='/post' component={Post} />
42+
<Route path='/user' component={User} />
43+
<Route render={() => <h1>404</h1>} />
44+
</Switch>
45+
</React.Suspense>
46+
</div>
47+
</div>
48+
</ThemeProvider>
49+
</Router>
50+
)
51+
}
52+
}
53+
54+
ReactDOM.render(
55+
<App />,
56+
document.getElementById('app')
57+
)

‎app/utils/api.js

+52
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
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

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export function formatDate (timestamp) {
2+
return new Date(timestamp * 1000)
3+
.toLocaleDateString("en-US", {
4+
hour: 'numeric' ,
5+
minute: 'numeric'
6+
})
7+
}

‎package-lock.json

+7,548
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json

+46
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
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+
}

‎webpack.config.js

+30
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
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+
}

0 commit comments

Comments
 (0)
Please sign in to comment.