├── .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 | 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 | |    |    |
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 |
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 |
14 |
15 |
16 |
21 | Top
22 |
23 |
24 |
25 |
29 | New
30 |
31 |
32 |
33 |
38 | {theme === 'light' ? '🔦' : '💡'}
39 |
40 |
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 |
82 |
88 |
89 |
90 | )}
91 | {loadingComments === true ? (
92 | loadingPost === false &&
93 | ) : (
94 |
95 | {comments.map((comment) => (
96 |
97 | ))}
98 |
99 | )}
100 |
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 |
12 | by {by}
13 | on {formatDate(time)}
14 | {typeof descendants === 'number' && (
15 |
16 | with {descendants} comments
17 |
18 | )}
19 |
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
48 | }
49 |
50 | if (state.error) {
51 | return {state.error}
52 | }
53 |
54 | return
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 |
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 | 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 {error}
;
67 | }
68 |
69 | return (
70 |
71 | {loadingUser === true ? (
72 |
73 | ) : (
74 |
75 | {user.id}
76 |
77 |
78 | joined {formatDate(user.created)}
79 |
80 |
81 | has {user.karma.toLocaleString()} karma
82 |
83 |
84 |
85 |
86 | )}
87 | {loadingPosts === true ? (
88 | loadingUser === false &&
89 | ) : (
90 |
91 | Posts
92 |
93 |
94 | )}
95 |
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 |
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 | 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 |
23 |
24 |
}>
25 |
26 | }
30 | />
31 | }
34 | />
35 |
36 |
37 | 404 } />
38 |
39 |
40 |
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 | }
--------------------------------------------------------------------------------