[Deleted]',
29 | }}
30 | />
31 | {comment.kids?.length > 0 && (
32 | <>
33 |
40 |
44 | {comment.kids.map(id => (
45 |
49 | ))}
50 |
51 | >
52 | )}
53 |
54 | ) : null
55 | }
56 |
57 | Comment.propTypes = {
58 | comments: PropTypes.object,
59 | id: PropTypes.number.isRequired,
60 | }
61 |
62 | export default connect(({ items }) => ({ comments: items }))(
63 | withSsr(styles)(Comment),
64 | )
65 |
--------------------------------------------------------------------------------
/src/components/Comment/styles.scss:
--------------------------------------------------------------------------------
1 | .comment-children .comment-children {
2 | margin-left: 1.5em;
3 | }
4 |
5 | .comment {
6 | border-top: 1px solid #eee;
7 | position: relative;
8 |
9 | .by,
10 | .text,
11 | .toggle {
12 | font-size: 0.9em;
13 | margin: 1em 0;
14 | }
15 |
16 | .by {
17 | color: #828282;
18 |
19 | a {
20 | color: #828282;
21 | text-decoration: underline;
22 | }
23 | }
24 |
25 | .text {
26 | overflow-wrap: break-word;
27 |
28 | a:hover {
29 | color: #ff6600;
30 | }
31 |
32 | pre {
33 | white-space: pre-wrap;
34 | }
35 | }
36 |
37 | .toggle {
38 | background-color: #fffbf2;
39 | padding: 0.3em 0.5em;
40 | border-radius: 4px;
41 |
42 | a {
43 | color: #828282;
44 | cursor: pointer;
45 | }
46 |
47 | &.open {
48 | padding: 0;
49 | background-color: transparent;
50 | margin-bottom: -0.5em;
51 | }
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/src/components/Item/index.js:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types'
2 | import React from 'react'
3 | import { Link } from 'react-router-dom'
4 |
5 | import styles from './styles.scss'
6 |
7 | import { withSsr, host, timeAgo } from 'utils'
8 |
9 | const Item = ({ item }) => (
10 |
11 | {item.score}
12 |
13 | {item.url ? (
14 | <>
15 |
20 | {item.title}
21 |
22 | ({host(item.url)})
23 | >
24 | ) : (
25 | {item.title}
26 | )}
27 |
28 |
29 |
30 | {item.type === 'job' ? null : (
31 |
32 | by {item.by}{' '}
33 |
34 | )}
35 | {timeAgo(item.time)} ago
36 | {item.type === 'job' ? null : (
37 |
38 | {' '}
39 | | {item.descendants} comments
40 |
41 | )}
42 |
43 | {item.type === 'story' ? null : (
44 | {' ' + item.type}
45 | )}
46 |
47 | )
48 |
49 | Item.propTypes = {
50 | item: PropTypes.object.isRequired,
51 | }
52 |
53 | export default withSsr(styles, true)(Item)
54 |
--------------------------------------------------------------------------------
/src/components/Item/styles.scss:
--------------------------------------------------------------------------------
1 | .news-item {
2 | background-color: #fff;
3 | padding: 20px 30px 20px 80px;
4 | border-bottom: 1px solid #eee;
5 | position: relative;
6 | line-height: 20px;
7 |
8 | .score {
9 | color: #00d8ff;
10 | font-size: 1.1em;
11 | font-weight: 700;
12 | position: absolute;
13 | top: 50%;
14 | left: 0;
15 | width: 80px;
16 | text-align: center;
17 | margin-top: -10px;
18 | }
19 |
20 | .meta,
21 | .host {
22 | font-size: 0.85em;
23 | color: #828282;
24 |
25 | a {
26 | color: #828282;
27 | text-decoration: underline;
28 | &:hover {
29 | color: #00d8ff;
30 | }
31 | }
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/components/Spinner/index.js:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types'
2 | import React from 'react'
3 |
4 | import styles from './styles.scss'
5 |
6 | import { withSsr } from 'utils'
7 |
8 | const Spinner = ({ show }) =>
9 | show ? (
10 |
26 | ) : null
27 |
28 | Spinner.propTypes = {
29 | show: PropTypes.bool.isRequired,
30 | }
31 |
32 | export default withSsr(styles)(Spinner)
33 |
--------------------------------------------------------------------------------
/src/components/Spinner/styles.scss:
--------------------------------------------------------------------------------
1 | @use "sass:math";
2 |
3 | $offset: 126;
4 | $duration: 1.4s;
5 |
6 | .spinner {
7 | transition: opacity 0.15s ease;
8 | animation: rotator $duration linear infinite;
9 | animation-play-state: paused;
10 | &.show {
11 | animation-play-state: running;
12 | }
13 | }
14 |
15 | @keyframes rotator {
16 | from {
17 | transform: scale(0.5) rotate(0deg);
18 | }
19 | to {
20 | transform: scale(0.5) rotate(270deg);
21 | }
22 | }
23 |
24 | .spinner .path {
25 | stroke: #00d8ff;
26 | stroke-dasharray: $offset;
27 | stroke-dashoffset: 0;
28 | transform-origin: center;
29 | animation: dash $duration ease-in-out infinite;
30 | }
31 |
32 | @keyframes dash {
33 | from {
34 | stroke-dashoffset: $offset;
35 | }
36 | 50% {
37 | stroke-dashoffset: math.div($offset, 2);
38 | transform: rotate(135deg);
39 | }
40 | to {
41 | stroke-dashoffset: $offset;
42 | transform: rotate(450deg);
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/src/entry-client.js:
--------------------------------------------------------------------------------
1 | import { ConnectedRouter } from 'connected-react-router'
2 | import React from 'react'
3 | // eslint-disable-next-line react/no-deprecated, sonar/deprecation
4 | import { hydrate } from 'react-dom'
5 | import Loadable from 'react-loadable'
6 | import { Provider } from 'react-redux'
7 |
8 | import App from 'App'
9 | import createStore, { history } from 'store'
10 |
11 | const store = createStore(window.__INITIAL_STATE__)
12 |
13 | if (!__DEV__) {
14 | delete window.__INITIAL_STATE__
15 | }
16 |
17 | const render = () =>
18 | Loadable.preloadReady().then(() =>
19 | hydrate(
20 |
21 |
22 |
23 |
24 | ,
25 | document.querySelector('#app'),
26 | ),
27 | )
28 |
29 | render()
30 |
31 | if (__DEV__) {
32 | // eslint-disable-next-line no-undef
33 | module.hot.accept('App', render)
34 | }
35 |
36 | if (
37 | !__DEV__ &&
38 | (location.protocol === 'https:' ||
39 | ['127.0.0.1', 'localhost'].includes(location.hostname)) &&
40 | navigator.serviceWorker
41 | ) {
42 | navigator.serviceWorker.register('/service-worker.js')
43 | }
44 |
--------------------------------------------------------------------------------
/src/entry-server.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import Loadable from 'react-loadable'
3 | import { Provider } from 'react-redux'
4 | import { StaticRouter } from 'react-router'
5 | import { matchRoutes } from 'react-router-config'
6 |
7 | import App, { routes } from 'App'
8 | import createStore from 'store'
9 |
10 | const preloadAll = Loadable.preloadAll()
11 |
12 | export default context =>
13 | // eslint-disable-next-line no-async-promise-executor
14 | new Promise(async (resolve, reject) => {
15 | await preloadAll
16 |
17 | const { ctx } = context
18 |
19 | const store = createStore()
20 |
21 | const matched = matchRoutes(routes, ctx.url)
22 |
23 | try {
24 | for (const {
25 | match,
26 | route: { component },
27 | } of matched) {
28 | let comp
29 |
30 | if (typeof component.preload === 'function') {
31 | comp = await component.preload({ match, store, context })
32 | }
33 |
34 | if (!comp) {
35 | continue
36 | }
37 |
38 | comp = (comp && comp.default) || comp
39 |
40 | if (typeof comp.preload === 'function') {
41 | await comp.preload({ match, store, context })
42 | }
43 | }
44 |
45 | const { status, url } = context
46 | if (status || url) {
47 | return reject(context)
48 | }
49 | } catch (e) {
50 | return reject(e)
51 | }
52 |
53 | Object.defineProperty(context, 'state', {
54 | get() {
55 | return store.getState()
56 | },
57 | })
58 |
59 | resolve(
60 |
61 |
65 |
66 |
67 | ,
68 | )
69 | })
70 |
--------------------------------------------------------------------------------
/src/store/actions.js:
--------------------------------------------------------------------------------
1 | import { activeIds } from './selectors.js'
2 | import TYPES from './types.js'
3 |
4 | import {
5 | fetchIdsByType as _fetchIdsByType,
6 | fetchItems as _fetchItems,
7 | fetchUser as _fetchUser,
8 | } from 'api'
9 |
10 | export const setLoading = loading => ({
11 | type: TYPES.SET_LOADING,
12 | loading,
13 | })
14 |
15 | export const setActiveType = activeType => ({
16 | type: TYPES.SET_ACTIVE_TYPE,
17 | activeType,
18 | })
19 |
20 | export const setList = (listType, ids) => ({
21 | type: TYPES.SET_LIST,
22 | listType,
23 | ids,
24 | })
25 |
26 | export const setItems = items => ({
27 | type: TYPES.SET_ITEMS,
28 | items,
29 | })
30 |
31 | export const setUser = (id, user) => ({
32 | type: TYPES.SET_USER,
33 | id,
34 | user,
35 | })
36 |
37 | export const fetchListData = (type, page) => dispatch => {
38 | dispatch(setActiveType(type))
39 | return _fetchIdsByType(type)
40 | .then(ids => dispatch(setList(type, ids)))
41 | .then(() => dispatch(ensureActiveItems(page)))
42 | }
43 |
44 | export const fetchItems = ids => (dispatch, getState) => {
45 | // on the client, the store itself serves as a cache.
46 | // only fetch items that we do not already have, or has expired (3 minutes)
47 | const now = Date.now()
48 | const state = getState()
49 | ids = ids.filter(id => {
50 | const item = state.items[id]
51 | if (!item) {
52 | return true
53 | }
54 | return now - item.__lastUpdated > 1000 * 60 * 3
55 | })
56 |
57 | return ids.length > 0
58 | ? _fetchItems(ids).then(items => dispatch(setItems(items)))
59 | : Promise.resolve()
60 | }
61 |
62 | export const ensureActiveItems = page => (dispatch, getState) =>
63 | dispatch(fetchItems(activeIds(getState(), page)))
64 |
65 | export const fetchUser = id => (dispatch, getState) =>
66 | getState().users[id]
67 | ? Promise.resolve()
68 | : _fetchUser(id).then(user => dispatch(setUser(id, user)))
69 |
--------------------------------------------------------------------------------
/src/store/index.js:
--------------------------------------------------------------------------------
1 | import { connectRouter, routerMiddleware } from 'connected-react-router'
2 | import { createBrowserHistory, createMemoryHistory } from 'history'
3 | import {
4 | compose,
5 | combineReducers,
6 | legacy_createStore as createStore,
7 | applyMiddleware,
8 | } from 'redux'
9 | import { thunk } from 'redux-thunk'
10 |
11 | import * as reducers from './reducers.js'
12 |
13 | export const history = __SERVER__
14 | ? createMemoryHistory()
15 | : createBrowserHistory()
16 |
17 | const createRootReducer = () =>
18 | combineReducers({
19 | router: connectRouter(history),
20 | ...reducers,
21 | })
22 |
23 | const composeEnhancers =
24 | (__DEV__ && !__SERVER__ && window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__) ||
25 | compose
26 |
27 | export default initialState =>
28 | createStore(
29 | createRootReducer(),
30 | initialState,
31 | composeEnhancers(applyMiddleware(routerMiddleware(history), thunk)),
32 | )
33 |
34 | export * from './actions.js'
35 | export * from './selectors.js'
36 |
--------------------------------------------------------------------------------
/src/store/reducers.js:
--------------------------------------------------------------------------------
1 | import TYPES from './types.js'
2 |
3 | export const loading = (state = false, action) => {
4 | switch (action.type) {
5 | case TYPES.SET_LOADING: {
6 | return action.loading
7 | }
8 | default: {
9 | return state
10 | }
11 | }
12 | }
13 |
14 | export const activeType = (state = null, action) => {
15 | switch (action.type) {
16 | case TYPES.SET_ACTIVE_TYPE: {
17 | return action.activeType
18 | }
19 | default: {
20 | return state
21 | }
22 | }
23 | }
24 |
25 | export const itemsPerPage = (state = 20) => state
26 |
27 | export const items = (state = {}, action) => {
28 | switch (action.type) {
29 | case TYPES.SET_ITEMS: {
30 | return {
31 | ...state,
32 | ...action.items.reduce((result, item) => {
33 | if (item) {
34 | result[item.id] = item
35 | }
36 | return result
37 | }, {}),
38 | }
39 | }
40 | default: {
41 | return state
42 | }
43 | }
44 | }
45 |
46 | export const users = (state = {}, action) => {
47 | switch (action.type) {
48 | case TYPES.SET_USER: {
49 | return {
50 | ...state,
51 | [action.id]: action.user || false,
52 | }
53 | }
54 | default: {
55 | return state
56 | }
57 | }
58 | }
59 |
60 | export const lists = (
61 | // eslint-disable-next-line unicorn/no-object-as-default-parameter
62 | state = {
63 | top: [],
64 | new: [],
65 | show: [],
66 | ask: [],
67 | job: [],
68 | },
69 | action,
70 | ) => {
71 | switch (action.type) {
72 | case TYPES.SET_LIST: {
73 | return {
74 | ...state,
75 | [action.listType]: action.ids,
76 | }
77 | }
78 | default: {
79 | return state
80 | }
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/src/store/selectors.js:
--------------------------------------------------------------------------------
1 | export const activeIds = (state, page = 1) => {
2 | const { activeType, itemsPerPage, lists } = state
3 |
4 | if (!activeType) {
5 | return []
6 | }
7 |
8 | const start = (page - 1) * itemsPerPage
9 | const end = page * itemsPerPage
10 |
11 | return lists[activeType].slice(start, end)
12 | }
13 |
14 | export const activeItems = (state, page) =>
15 | activeIds(state, page)
16 | .map(id => state.items[id])
17 | .filter(Boolean)
18 |
--------------------------------------------------------------------------------
/src/store/types.js:
--------------------------------------------------------------------------------
1 | export default {
2 | SET_LOADING: 'SET_LOADING',
3 | SET_ACTIVE_TYPE: 'SET_ACTIVE_TYPE',
4 | SET_LIST: 'SET_LIST',
5 | SET_ITEMS: 'SET_ITEMS',
6 | SET_USER: 'SET_USER',
7 | }
8 |
--------------------------------------------------------------------------------
/src/styles/app.scss:
--------------------------------------------------------------------------------
1 | body {
2 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
3 | Ubuntu, Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
4 | font-size: 15px;
5 | background-color: #f2f3f5;
6 | margin: 0;
7 | padding-top: 55px;
8 | color: #34495e;
9 | overflow-y: scroll;
10 | }
11 |
12 | a {
13 | color: #34495e;
14 | text-decoration: none;
15 | }
16 |
17 | .header {
18 | background-color: #20232a;
19 | position: fixed;
20 | z-index: 999;
21 | top: 0;
22 | left: 0;
23 | right: 0;
24 |
25 | a {
26 | position: relative;
27 | padding: 15px 25px;
28 | color: rgba(255, 255, 255, 0.8);
29 | line-height: 24px;
30 | display: inline-block;
31 | vertical-align: middle;
32 | font-weight: 300;
33 | letter-spacing: 0.075em;
34 | transition: all 0.5s;
35 |
36 | &.active,
37 | &:hover {
38 | color: #00d8ff;
39 | }
40 | }
41 |
42 | .github {
43 | display: inline-flex;
44 | color: #fff;
45 | font-size: 0.9em;
46 | padding-right: 0;
47 | margin-left: auto;
48 |
49 | img {
50 | margin-left: 2px;
51 | }
52 | }
53 |
54 | @media (max-width: 800px) {
55 | .header-content,
56 | a {
57 | padding-left: 15px;
58 | padding-right: 15px;
59 | }
60 | }
61 |
62 | @media (max-width: 600px) {
63 | a {
64 | padding-left: 10px;
65 | padding-right: 10px;
66 | }
67 | }
68 |
69 | @media (max-width: 500px) {
70 | .github {
71 | display: none;
72 | }
73 | }
74 | }
75 |
76 | .logo {
77 | width: 24px;
78 | }
79 |
80 | .header-content {
81 | display: flex;
82 | margin: 0px auto;
83 | max-width: 800px;
84 | box-sizing: border-box;
85 |
86 | > a {
87 | padding-left: 0;
88 | }
89 | }
90 |
91 | .inner {
92 | display: flex;
93 | overflow: auto hidden;
94 |
95 | a {
96 | &:focus {
97 | background-color: #373940;
98 | }
99 |
100 | &.active {
101 | font-weight: 400;
102 |
103 | &:focus {
104 | color: #fff;
105 | }
106 |
107 | &:after {
108 | content: '';
109 | position: absolute;
110 | bottom: 0;
111 | left: 50%;
112 | transform: translate3d(-50%, 0, 0);
113 | width: 100%;
114 | height: 2px;
115 | background-color: #00d8ff;
116 | }
117 | }
118 | }
119 | }
120 |
121 | .view {
122 | max-width: 800px;
123 | margin: 0 auto;
124 | position: relative;
125 | opacity: 0;
126 | transition: all 0.2s ease;
127 | }
128 |
--------------------------------------------------------------------------------
/src/utils/index.js:
--------------------------------------------------------------------------------
1 | export * from './ssr.js'
2 |
3 | export function host(url) {
4 | const host = url.replace(/^https?:\/\//, '').replace(/\/.*$/, '')
5 | const parts = host.split('.').slice(-1 * 3)
6 | if (parts[0] === 'www') parts.shift()
7 | return parts.join('.')
8 | }
9 |
10 | export function timeAgo(time) {
11 | const between = Date.now() / 1000 - Number(time)
12 | if (between < 3600) {
13 | return pluralize(Math.trunc(between / 60), ' minute')
14 | }
15 | if (between < 60 * 60 * 24) {
16 | return pluralize(Math.trunc((between / 60) * 60), ' hour')
17 | }
18 | return pluralize(Math.trunc((between / 60) * 60 * 24), ' day')
19 | }
20 |
21 | function pluralize(time, label) {
22 | if (time === 1) {
23 | return time + label
24 | }
25 | return time + label + 's'
26 | }
27 |
--------------------------------------------------------------------------------
/src/utils/ssr.js:
--------------------------------------------------------------------------------
1 | import hoistStatics from 'hoist-non-react-statics'
2 | import PropTypes from 'prop-types'
3 | import React from 'react'
4 | import { withRouter } from 'react-router'
5 |
6 | // eslint-disable-next-line sonarjs/cognitive-complexity
7 | export const withSsr = (styles, router = true, title) => {
8 | if (typeof router !== 'boolean') {
9 | title = router
10 | router = true
11 | }
12 |
13 | return Component => {
14 | class SsrComponent extends React.PureComponent {
15 | static displayName = `Ssr${
16 | Component.displayName || Component.name || 'Component'
17 | }`
18 |
19 | static propTypes = {
20 | staticContext: PropTypes.object,
21 | }
22 |
23 | constructor(props, context) {
24 | super(props, context)
25 | if (styles.__inject__) {
26 | styles.__inject__(this.props.staticContext)
27 | }
28 |
29 | this.setTitle()
30 | }
31 |
32 | setTitle() {
33 | const t = typeof title === 'function' ? title.call(this, this) : title
34 |
35 | if (!t) {
36 | return
37 | }
38 |
39 | if (__SERVER__) {
40 | this.props.staticContext.title = `React Hackernews | ${t}`
41 | return
42 | }
43 |
44 | Promise.resolve(t).then(title => {
45 | if (title) {
46 | document.title = `React Hackernews | ${title}`
47 | }
48 | })
49 | }
50 |
51 | render() {
52 | return
53 | }
54 | }
55 |
56 | return hoistStatics(
57 | router ? withRouter(SsrComponent) : SsrComponent,
58 | Component,
59 | )
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/src/views/CreateListView.js:
--------------------------------------------------------------------------------
1 | import hoistStatics from 'hoist-non-react-statics'
2 | import PropTypes from 'prop-types'
3 | import React from 'react'
4 | import { connect } from 'react-redux'
5 |
6 | import ItemList from './ItemList/index.js'
7 |
8 | import { activeItems, fetchListData } from 'store'
9 |
10 | export default type => {
11 | @connect(
12 | (state, props) => ({
13 | activeItems: activeItems(state, props.match.params.page),
14 | }),
15 | (dispatch, props) => ({
16 | fetchListData: () =>
17 | dispatch(fetchListData(type, props.match.params.page)),
18 | }),
19 | )
20 | class ListView extends React.PureComponent {
21 | static propTypes = {
22 | match: PropTypes.object.isRequired,
23 | }
24 |
25 | static preload({ store, match }) {
26 | return store.dispatch(fetchListData(type, match.params.page))
27 | }
28 |
29 | render() {
30 | return (
31 |
35 | )
36 | }
37 | }
38 |
39 | return hoistStatics(ListView, ItemList)
40 | }
41 |
--------------------------------------------------------------------------------
/src/views/ItemList/index.js:
--------------------------------------------------------------------------------
1 | import { startCase } from 'lodash'
2 | import { pathToRegexp } from 'path-to-regexp'
3 | import PropTypes from 'prop-types'
4 | import React from 'react'
5 | import { connect } from 'react-redux'
6 | import { Link } from 'react-router-dom'
7 | import { CSSTransition, TransitionGroup } from 'react-transition-group'
8 |
9 | import styles from './styles.scss'
10 |
11 | import { watchList } from 'api'
12 | import Item from 'components/Item/index.js'
13 | import Spinner from 'components/Spinner/index.js'
14 | import {
15 | activeItems,
16 | setLoading,
17 | setList,
18 | ensureActiveItems,
19 | fetchListData,
20 | } from 'store'
21 | import { withSsr } from 'utils'
22 |
23 | @connect(
24 | (state, props) => ({
25 | loading: state.loading,
26 | activeItems: activeItems(state, props.match.params.page),
27 | itemsPerPage: state.itemsPerPage,
28 | lists: state.lists,
29 | }),
30 | (
31 | dispatch,
32 | {
33 | type,
34 | match: {
35 | params: { page },
36 | },
37 | },
38 | ) => ({
39 | setLoading: loading => dispatch(setLoading(loading)),
40 | setList: (listType, ids) => dispatch(setList(listType, ids)),
41 | fetchListData: () => dispatch(fetchListData(type, page)),
42 | ensureActiveItems: () => dispatch(ensureActiveItems(page)),
43 | }),
44 | )
45 | @withSsr(styles, false, ({ props }) => startCase(props.type))
46 | export default class ItemList extends React.PureComponent {
47 | static propTypes = {
48 | loading: PropTypes.bool.isRequired,
49 | activeItems: PropTypes.array.isRequired,
50 | match: PropTypes.object.isRequired,
51 | location: PropTypes.object.isRequired,
52 | itemsPerPage: PropTypes.number.isRequired,
53 | lists: PropTypes.object.isRequired,
54 | type: PropTypes.string.isRequired,
55 | fetchListData: PropTypes.func.isRequired,
56 | history: PropTypes.object.isRequired,
57 | setLoading: PropTypes.func.isRequired,
58 | setList: PropTypes.func.isRequired,
59 | ensureActiveItems: PropTypes.func.isRequired,
60 | }
61 |
62 | state = {
63 | displayedPage: this.page,
64 | displayedItems: this.props.activeItems,
65 | itemTransition: 'item',
66 | transition: 'slide-right',
67 | }
68 |
69 | get page() {
70 | return Number(this.props.match.params.page) || 1
71 | }
72 |
73 | get maxPage() {
74 | const { itemsPerPage, lists, type } = this.props
75 | return Math.ceil(lists[type].length / itemsPerPage)
76 | }
77 |
78 | get hasMore() {
79 | return this.page < this.maxPage
80 | }
81 |
82 | loadItems(to = this.page, from = -1) {
83 | this.props.setLoading(true)
84 |
85 | this.props.fetchListData().then(() => {
86 | if (this.page < 0 || this.page > this.maxPage) {
87 | this.props.history.replace(`/${this.props.type}`)
88 | return
89 | }
90 |
91 | const transition =
92 | from === -1 ? '' : to > from ? 'slide-left' : 'slide-right'
93 |
94 | this.setState(
95 | {
96 | displayedPage: -1,
97 | itemTransition: '',
98 | transition,
99 | },
100 | () =>
101 | setTimeout(
102 | () => {
103 | this.setState(
104 | {
105 | displayedPage: to,
106 | displayedItems: this.props.activeItems,
107 | },
108 | () => {
109 | this.props.setLoading(false)
110 | },
111 | )
112 | },
113 | transition ? 500 : 0,
114 | ),
115 | )
116 | })
117 | }
118 |
119 | isSameLocation(prev, curr) {
120 | return prev.pathname === curr.pathname && prev.search === curr.search
121 | }
122 |
123 | componentDidMount() {
124 | const { history, type, match } = this.props
125 |
126 | this.unwatchList = watchList(type, ids => {
127 | this.props.setList(type, ids)
128 | this.props.ensureActiveItems().then(() => {
129 | this.setState({
130 | displayedItems: this.props.activeItems,
131 | })
132 | })
133 | })
134 |
135 | this.unwatchPage = history.listen(location => {
136 | const {
137 | params: { page: prevPage },
138 | path,
139 | } = match
140 | if (
141 | this.isSameLocation(this.props.location, location) ||
142 | !pathToRegexp(path).test(location.pathname)
143 | ) {
144 | return
145 | }
146 | setTimeout(() =>
147 | // eslint-disable-next-line unicorn/consistent-destructuring
148 | this.loadItems(this.props.match.params.page, prevPage || 1),
149 | )
150 | })
151 | }
152 |
153 | componentWillUnmount() {
154 | this.unwatchList()
155 | this.unwatchPage()
156 | }
157 |
158 | render() {
159 | const { page, maxPage, hasMore, props, state } = this
160 | const { loading, type } = props
161 | const { displayedItems, displayedPage, itemTransition, transition } = state
162 |
163 | return (
164 |
165 |
166 | {page > 1 ? (
167 |
< prev
168 | ) : (
169 |
< prev
170 | )}
171 |
172 | {page}/{maxPage}
173 |
174 | {hasMore ? (
175 |
more >
176 | ) : (
177 |
more >
178 | )}
179 |
180 |
0}
182 | classNames={transition}
183 | timeout={transition ? 500 : 0}
184 | onEntered={() => {
185 | this.setState({
186 | itemTransition: 'item',
187 | })
188 | }}
189 | >
190 |
191 | {maxPage && !loading ? (
192 |
193 | {displayedItems.map(item => (
194 |
199 |
200 |
201 | ))}
202 |
203 | ) : (
204 |
205 |
206 |
207 | )}
208 |
209 |
210 |
211 | )
212 | }
213 | }
214 |
--------------------------------------------------------------------------------
/src/views/ItemList/styles.scss:
--------------------------------------------------------------------------------
1 | .news-view {
2 | margin-top: 45px;
3 | }
4 |
5 | .news-list-nav,
6 | .news-list {
7 | background-color: #fff;
8 | border-radius: 2px;
9 | }
10 |
11 | .news-list-nav {
12 | padding: 15px 30px;
13 | position: fixed;
14 | text-align: center;
15 | top: 55px;
16 | left: 0;
17 | right: 0;
18 | z-index: 998;
19 | box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
20 | a {
21 | margin: 0 1em;
22 | }
23 | .disabled {
24 | color: #ccc;
25 | }
26 | }
27 |
28 | .news-list {
29 | position: absolute;
30 | margin: 30px 0;
31 | width: 100%;
32 | transition: all 0.5s cubic-bezier(0.55, 0, 0.1, 1);
33 |
34 | .loading {
35 | text-align: center;
36 | padding: 20px;
37 | }
38 |
39 | ul {
40 | list-style-type: none;
41 | padding: 0;
42 | margin: 0;
43 | }
44 | }
45 |
46 | .slide-right-enter,
47 | .slide-right-exit.slide-right-exit-active {
48 | opacity: 0;
49 | transform: translate(30px, 0);
50 | }
51 |
52 | .slide-left-enter,
53 | .slide-left-exit.slide-left-exit-active {
54 | opacity: 0;
55 | transform: translate(-30px, 0);
56 | }
57 |
58 | .slide-left-enter.slide-left-enter-active,
59 | .slide-right-enter.slide-right-enter-active {
60 | opacity: 1;
61 | transform: translate(0, 0);
62 | }
63 |
64 | .item-enter-active,
65 | .item-exit-active {
66 | position: absolute;
67 | z-index: 1;
68 | transition: all 0.5s cubic-bezier(0.55, 0, 0.1, 1);
69 | }
70 |
71 | .item-enter,
72 | .item-exit.item-exit-active {
73 | opacity: 0;
74 | transform: translate(30px, 0);
75 | }
76 |
77 | .item-exit,
78 | .item-enter.item-enter-active {
79 | opacity: 1;
80 | transform: translate(0, 0);
81 | }
82 |
83 | @media (max-width: 800px) {
84 | .news-list {
85 | margin: 10px 0;
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/src/views/ItemView/index.js:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types'
2 | import React from 'react'
3 | import { connect } from 'react-redux'
4 | import { Link } from 'react-router-dom'
5 |
6 | import styles from './styles.scss'
7 |
8 | import Comment from 'components/Comment/index.js'
9 | import Spinner from 'components/Spinner/index.js'
10 | import { fetchItems } from 'store'
11 | import { withSsr, host, timeAgo } from 'utils'
12 |
13 | @connect(
14 | ({ items }) => ({ items }),
15 | dispatch => ({
16 | fetchItems: ids => dispatch(fetchItems(ids)),
17 | }),
18 | )
19 | @withSsr(styles, false, ({ props }) => {
20 | const {
21 | items,
22 | match: {
23 | params: { id },
24 | },
25 | } = props
26 | return items[id] && items[id].title
27 | })
28 | export default class ItemView extends React.PureComponent {
29 | static propTypes = {
30 | items: PropTypes.object.isRequired,
31 | match: PropTypes.object.isRequired,
32 | fetchItems: PropTypes.func.isRequired,
33 | }
34 |
35 | state = {
36 | loading: false,
37 | }
38 |
39 | static preload({ match, store }) {
40 | const { id } = match.params
41 | return store.dispatch(fetchItems([id]))
42 | }
43 |
44 | get item() {
45 | return this.props.items[this.props.match.params.id]
46 | }
47 |
48 | fetchItems() {
49 | const { item } = this
50 |
51 | if (!item?.kids) {
52 | return
53 | }
54 |
55 | this.setState({
56 | loading: true,
57 | })
58 |
59 | this.fetchComments(item).then(() =>
60 | this.setState({
61 | loading: false,
62 | }),
63 | )
64 | }
65 |
66 | fetchComments(item) {
67 | if (item?.kids) {
68 | return this.props
69 | .fetchItems(item.kids)
70 | .then(() =>
71 | Promise.all(
72 | item.kids.map(id => this.fetchComments(this.props.items[id])),
73 | ),
74 | )
75 | }
76 | }
77 |
78 | componentDidMount() {
79 | this.fetchItems()
80 | }
81 |
82 | render() {
83 | const { loading } = this.state
84 | const { item } = this
85 |
86 | return item ? (
87 |
88 |
89 |
94 | {item.title}
95 |
96 | {item.url ?
({host(item.url)}) : null}
97 |
98 | {item.score} points | by{' '}
99 | {item.by}
100 | {' ' + timeAgo(item.time)} ago
101 |
102 |
103 |
104 |
105 | {item.kids ? item.descendants + ' comments' : 'No comments yet.'}
106 |
107 |
108 | {loading || !item.kids ? null : (
109 |
110 | {item.kids.map(id => (
111 |
115 | ))}
116 |
117 | )}
118 |
119 |
120 | ) : null
121 | }
122 | }
123 |
--------------------------------------------------------------------------------
/src/views/ItemView/styles.scss:
--------------------------------------------------------------------------------
1 | .item-view-header {
2 | background-color: #fff;
3 | padding: 1.8em 2em 1em;
4 | box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
5 |
6 | h1 {
7 | display: inline;
8 | font-size: 1.5em;
9 | margin: 0;
10 | margin-right: 0.5em;
11 | }
12 |
13 | .host,
14 | .meta,
15 | .meta a {
16 | color: #828282;
17 | }
18 |
19 | .meta a {
20 | text-decoration: underline;
21 | }
22 | }
23 |
24 | .item-view-comments {
25 | background-color: #fff;
26 | margin-top: 10px;
27 | padding: 0 2em 0.5em;
28 | }
29 |
30 | .item-view-comments-header {
31 | margin: 0;
32 | font-size: 1.1em;
33 | padding: 1em 0;
34 | position: relative;
35 |
36 | .spinner {
37 | display: inline-block;
38 | margin: -15px 0;
39 | }
40 | }
41 |
42 | .comment-children {
43 | list-style-type: none;
44 | padding: 0;
45 | margin: 0;
46 | }
47 |
48 | @media (max-width: 600px) {
49 | .item-view-header h1 {
50 | font-size: 1.25em;
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/src/views/UserView/index.js:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types'
2 | import React from 'react'
3 | import { connect } from 'react-redux'
4 |
5 | import styles from './styles.scss'
6 |
7 | import { fetchUser } from 'store'
8 | import { withSsr, timeAgo } from 'utils'
9 |
10 | const USER_NOT_FOUND = 'User Not Found'
11 |
12 | @connect(
13 | ({ users }) => ({ users }),
14 | (dispatch, props) => ({
15 | fetchUser: () => dispatch(fetchUser(props.match.params.id)),
16 | }),
17 | )
18 | @withSsr(styles, false, self => {
19 | const {
20 | users,
21 | match: {
22 | params: { id },
23 | },
24 | } = self.props
25 | const user = users[id]
26 |
27 | if (user) {
28 | return id
29 | }
30 |
31 | if (user === false) {
32 | return USER_NOT_FOUND
33 | }
34 |
35 | if (!__SERVER__) {
36 | return self.props.fetchUser().then(() => (users[id] ? id : USER_NOT_FOUND))
37 | }
38 | })
39 | export default class UserView extends React.PureComponent {
40 | static propTypes = {
41 | match: PropTypes.object.isRequired,
42 | users: PropTypes.object.isRequired,
43 | // eslint-disable-next-line react/no-unused-prop-types
44 | fetchUser: PropTypes.func.isRequired,
45 | }
46 |
47 | get user() {
48 | const { match, users } = this.props
49 | return users[match.params.id]
50 | }
51 |
52 | static preload({ match, store }) {
53 | return store.dispatch(fetchUser(match.params.id))
54 | }
55 |
56 | render() {
57 | const { user } = this
58 |
59 | return (
60 |
61 | {user ? (
62 | <>
63 |
User : {user.id}
64 |
65 | -
66 | Created: {timeAgo(user.created)}{' '}
67 | ago
68 |
69 | -
70 | Karma: {user.karma}
71 |
72 | {user.about ? (
73 |
77 | ) : null}
78 |
79 |
80 |
81 | submissions
82 | {' '}
83 | |
84 |
85 | comments
86 |
87 |
88 | >
89 | ) : user === false ? (
90 |
User not found.
91 | ) : null}
92 |
93 | )
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/src/views/UserView/styles.scss:
--------------------------------------------------------------------------------
1 | .user-view {
2 | background-color: #fff;
3 | box-sizing: border-box;
4 | padding: 2em 3em;
5 | h1 {
6 | margin: 0;
7 | font-size: 1.5em;
8 | }
9 | .meta {
10 | list-style-type: none;
11 | padding: 0;
12 | }
13 | .label {
14 | display: inline-block;
15 | min-width: 4em;
16 | }
17 | .about {
18 | margin: 1em 0;
19 | }
20 | .links a {
21 | text-decoration: underline;
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-hackernews",
3 | "version": 2,
4 | "alias": [
5 | "react-hn.vercel.app"
6 | ],
7 | "github": {
8 | "silent": true
9 | },
10 | "rewrites": [
11 | {
12 | "source": "/(.*)",
13 | "destination": "https://react-hacknews.herokuapp.com/$1"
14 | }
15 | ]
16 | }
17 |
--------------------------------------------------------------------------------