├── .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 with TypeScript Course Curriculum - Hacker News Clone

11 | 12 | ### Info 13 | 14 | This is the repository for UI.dev's "React with TypeScript" course curriculum project. 15 | 16 | For more information on the course, visit **[ui.dev/react-with-typescript/](https://ui.dev/react-with-typescript/)**. 17 | 18 | ### Assignment 19 | 20 | Clone this repo and refactor it to use TypeScript. 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 | ### [Alex Anderson](https://twitter.com/ralex1993) 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 function Loading ({ text='Loading', speed=300 }) { 16 | const [content, setContent] = React.useState(text) 17 | 18 | React.useEffect(() => { 19 | const id = window.setInterval(() => { 20 | setContent((content) => { 21 | return content === `${text}...` 22 | ? text 23 | : `${content}.` 24 | }) 25 | }, speed) 26 | 27 | return () => window.clearInterval(id) 28 | }, [speed, text]) 29 | 30 | return ( 31 |

32 | {content} 33 |

34 | ) 35 | } 36 | 37 | Loading.propTypes = { 38 | text: PropTypes.string, 39 | speed: PropTypes.number, 40 | } 41 | -------------------------------------------------------------------------------- /app/components/Nav.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ThemeContext 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 ({ toggleTheme }) { 10 | const theme = React.useContext(ThemeContext) 11 | 12 | return ( 13 | 41 | ) 42 | } -------------------------------------------------------------------------------- /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 | function postReducer(state, action) { 10 | if (action.type === "fetch") { 11 | return { 12 | ...state, 13 | loadingPost: true, 14 | loadingComments: true, 15 | }; 16 | } else if (action.type === "post") { 17 | return { 18 | ...state, 19 | loadingPost: false, 20 | post: action.post, 21 | }; 22 | } else if (action.type === "comments") { 23 | return { 24 | ...state, 25 | loadingComments: false, 26 | comments: action.comments, 27 | }; 28 | } else if (action.type === "error") { 29 | return { 30 | ...state, 31 | loadingComments: false, 32 | loadingPost: false, 33 | error: action.error, 34 | }; 35 | } else { 36 | throw new Error(`That action type is not supported.`); 37 | } 38 | } 39 | 40 | export default function Post({ location }) { 41 | const { id } = queryString.parse(location.search); 42 | const [state, dispatch] = React.useReducer(postReducer, { 43 | post: null, 44 | loadingPost: true, 45 | comments: null, 46 | loadingComments: true, 47 | error: null, 48 | }); 49 | 50 | const { post, loadingPost, comments, loadingComments, error } = state; 51 | 52 | React.useEffect(() => { 53 | dispatch({ type: "fetch" }); 54 | 55 | fetchItem(id) 56 | .then((post) => { 57 | dispatch({ type: "post", post }); 58 | return fetchComments(post.kids || []); 59 | }) 60 | .then((comments) => dispatch({ type: "comments", comments })) 61 | .catch(({ message }) => 62 | dispatch({ 63 | type: "error", 64 | error: message, 65 | }) 66 | ); 67 | }, [id]); 68 | 69 | if (error) { 70 | return

{error}

; 71 | } 72 | 73 | return ( 74 | 75 | {loadingPost === true ? ( 76 | 77 | ) : ( 78 | 79 |

80 | 81 | </h1> 82 | <PostMetaInfo 83 | by={post.by} 84 | time={post.time} 85 | id={post.id} 86 | descendants={post.descendants} 87 | /> 88 | <p dangerouslySetInnerHTML={{ __html: post.text }} /> 89 | </React.Fragment> 90 | )} 91 | {loadingComments === true ? ( 92 | loadingPost === false && <Loading text="Fetching comments" /> 93 | ) : ( 94 | <React.Fragment> 95 | {comments.map((comment) => ( 96 | <Comment key={comment.id} comment={comment} /> 97 | ))} 98 | </React.Fragment> 99 | )} 100 | </React.Fragment> 101 | ); 102 | } 103 | -------------------------------------------------------------------------------- /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 ThemeContext from '../contexts/theme' 6 | 7 | export default function PostMetaInfo ({ by, time, id, descendants }) { 8 | const theme = React.useContext(ThemeContext) 9 | 10 | return ( 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 | } 22 | 23 | PostMetaInfo.propTypes = { 24 | by: PropTypes.string.isRequired, 25 | time: PropTypes.number.isRequired, 26 | id: PropTypes.number.isRequired, 27 | descendants: PropTypes.number, 28 | } -------------------------------------------------------------------------------- /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 | function postsReducer (state, action) { 8 | if (action.type === 'fetch') { 9 | return { 10 | posts: null, 11 | error: null, 12 | loading: true 13 | } 14 | } else if (action.type === 'success') { 15 | return { 16 | posts: action.posts, 17 | error: null, 18 | loading: false, 19 | } 20 | } else if (action.type === 'error') { 21 | return { 22 | posts: state.posts, 23 | error: action.message, 24 | loading: false 25 | } 26 | } else { 27 | throw new Error(`That action type is not supported.`) 28 | } 29 | } 30 | 31 | export default function Posts ({ type }) { 32 | const [state, dispatch] = React.useReducer( 33 | postsReducer, 34 | { posts: null, error: null, loading: true } 35 | ) 36 | 37 | React.useEffect(() => { 38 | dispatch({ type: 'fetch' }) 39 | 40 | fetchMainPosts(type) 41 | .then((posts) => dispatch({ type: 'success', posts })) 42 | .catch(({ message }) => dispatch({ type: 'error', error: message })) 43 | }, [type]) 44 | 45 | 46 | if (state.loading === true) { 47 | return <Loading /> 48 | } 49 | 50 | if (state.error) { 51 | return <p className='center-text error'>{state.error}</p> 52 | } 53 | 54 | return <PostsList posts={state.posts} /> 55 | } 56 | 57 | Posts.propTypes = { 58 | type: PropTypes.oneOf(['top', 'new']) 59 | } -------------------------------------------------------------------------------- /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 | function postReducer(state, action) { 9 | if (action.type === "fetch") { 10 | return { 11 | ...state, 12 | loadingUser: true, 13 | loadingPosts: true, 14 | }; 15 | } else if (action.type === "user") { 16 | return { 17 | ...state, 18 | user: action.user, 19 | loadingUser: false, 20 | }; 21 | } else if (action.type === "posts") { 22 | return { 23 | ...state, 24 | posts: action.posts, 25 | loadingPosts: false, 26 | error: null, 27 | }; 28 | } else if (action.type === "error") { 29 | return { 30 | ...state, 31 | error: action.message, 32 | loadingPosts: false, 33 | loadingUser: false, 34 | }; 35 | } else { 36 | throw new Error(`That action type is not supported.`); 37 | } 38 | } 39 | 40 | export default function User({ location }) { 41 | const { id } = queryString.parse(location.search); 42 | 43 | const [state, dispatch] = React.useReducer(postReducer, { 44 | user: null, 45 | loadingUser: true, 46 | posts: null, 47 | loadingPosts: true, 48 | error: null, 49 | }); 50 | 51 | React.useEffect(() => { 52 | dispatch({ type: "fetch" }); 53 | 54 | fetchUser(id) 55 | .then((user) => { 56 | dispatch({ type: "user", user }); 57 | return fetchPosts(user.submitted.slice(0, 30)); 58 | }) 59 | .then((posts) => dispatch({ type: "posts", posts })) 60 | .catch(({ message }) => dispatch({ type: "error", message })); 61 | }, [id]); 62 | 63 | const { user, posts, loadingUser, loadingPosts, error } = state; 64 | 65 | if (error) { 66 | return <p className="center-text error">{error}</p>; 67 | } 68 | 69 | return ( 70 | <React.Fragment> 71 | {loadingUser === true ? ( 72 | <Loading text="Fetching User" /> 73 | ) : ( 74 | <React.Fragment> 75 | <h1 className="header">{user.id}</h1> 76 | <div className="meta-info-light"> 77 | <span> 78 | joined <b>{formatDate(user.created)}</b> 79 | </span> 80 | <span> 81 | has <b>{user.karma.toLocaleString()}</b> karma 82 | </span> 83 | </div> 84 | <p dangerouslySetInnerHTML={{ __html: user.about }} /> 85 | </React.Fragment> 86 | )} 87 | {loadingPosts === true ? ( 88 | loadingUser === false && <Loading text="Fetching posts" /> 89 | ) : ( 90 | <React.Fragment> 91 | <h2>Posts</h2> 92 | <PostsList posts={posts} /> 93 | </React.Fragment> 94 | )} 95 | </React.Fragment> 96 | ); 97 | } 98 | -------------------------------------------------------------------------------- /app/contexts/theme.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const ThemeContext = React.createContext() 4 | 5 | export default ThemeContext 6 | export const ThemeConsumer = ThemeContext.Consumer 7 | export const ThemeProvider = ThemeContext.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 | function App () { 14 | const [theme, setTheme] = React.useState('light') 15 | const toggleTheme = () => setTheme((t) => t === 'light' ? 'dark' : 'light') 16 | 17 | return ( 18 | 19 | 20 |
21 |
22 |
41 |
42 |
43 |
44 | ) 45 | } 46 | 47 | ReactDOM.render( 48 | , 49 | document.getElementById('app') 50 | ) -------------------------------------------------------------------------------- /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-with-typescript-course-curriculum", 3 | "version": "1.0.0", 4 | "description": "Curriculum for ui.dev React with TypeScript 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 | } --------------------------------------------------------------------------------