├── .gitignore
├── README.md
├── package.json
├── public
├── favicon.ico
└── index.html
├── src
├── components
│ ├── Comment
│ │ ├── index.js
│ │ └── style.css
│ ├── Placeholder
│ │ ├── index.js
│ │ └── style.css
│ └── index.js
├── config
│ ├── constants.js
│ ├── routes.js
│ └── schemas.js
├── configureStore.js
├── containers
│ ├── App
│ │ ├── index.js
│ │ ├── logo.png
│ │ ├── logo.svg
│ │ └── style.css
│ ├── Story
│ │ ├── DetailPage
│ │ │ ├── Item
│ │ │ │ ├── index.js
│ │ │ │ └── style.css
│ │ │ └── index.js
│ │ ├── ListPage
│ │ │ ├── List
│ │ │ │ ├── Item
│ │ │ │ │ ├── index.js
│ │ │ │ │ └── style.css
│ │ │ │ ├── index.js
│ │ │ │ └── style.css
│ │ │ └── index.js
│ │ └── index.js
│ └── User
│ │ ├── DetailPage
│ │ ├── Item
│ │ │ ├── index.js
│ │ │ └── style.css
│ │ └── index.js
│ │ └── index.js
├── helpers
│ └── index.js
├── index.js
├── modules
│ ├── entity.js
│ ├── root.js
│ ├── story.js
│ └── user.js
└── services
│ └── db.js
└── yarn.lock
/.gitignore:
--------------------------------------------------------------------------------
1 | # See http://help.github.com/ignore-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | node_modules
5 |
6 | # testing
7 | coverage
8 |
9 | # production
10 | build
11 |
12 | # misc
13 | .DS_Store
14 | .env
15 | npm-debug.log
16 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Rx-HackerNews
2 |
3 | HackerNews clone built with React/Redux/redux-observable. Ports from [vue-hackernews-2.0](https://github.com/vuejs/vue-hackernews-2.0).
4 |
5 |
6 |
7 |
8 |
9 | Live Demo
10 |
11 |
12 |
13 | ## Build Setup
14 |
15 | ```bash
16 | # install dependencies
17 | yarn install
18 |
19 | # serve in development mode
20 | yarn start
21 | ```
22 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "rx-hackernews",
3 | "version": "0.1.0",
4 | "private": true,
5 | "devDependencies": {
6 | "list": "^0.9.5",
7 | "react-scripts": "0.8.4"
8 | },
9 | "dependencies": {
10 | "firebase": "^3.6.4",
11 | "lodash": "^4.17.4",
12 | "normalizr": "2.3.0",
13 | "react": "^15.4.1",
14 | "react-addons-css-transition-group": "^15.4.1",
15 | "react-dom": "^15.4.1",
16 | "react-redux": "^5.0.1",
17 | "react-router": "^3.0.0",
18 | "redux": "^3.6.0",
19 | "redux-logger": "^2.7.4",
20 | "redux-observable": "^0.12.2",
21 | "reselect": "^2.5.4",
22 | "rxjs": "^5.0.2"
23 | },
24 | "scripts": {
25 | "now-start": "list ./build --single",
26 | "start": "react-scripts start",
27 | "build": "react-scripts build && echo '/* /index.html 200' > build/_redirects",
28 | "test": "react-scripts test --env=jsdom",
29 | "eject": "react-scripts eject",
30 | "deploy": "now"
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yesmeck/rx-hackernews/46dc9266725b29900812c5f4effa6e711c7c3d56/public/favicon.ico
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
16 | React App
17 |
18 |
19 |
20 |
30 |
31 |
32 |
--------------------------------------------------------------------------------
/src/components/Comment/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import { connect } from 'react-redux'
3 | import { Link } from 'react-router'
4 | import { timeAgo } from '../../helpers'
5 | import './style.css'
6 |
7 | export function pluralize(time) {
8 | if (time === 1) {
9 | return time + ' reply'
10 | }
11 | return time + ' replies'
12 | }
13 |
14 | class Comment extends Component {
15 | constructor(props) {
16 | super(props)
17 |
18 | this.state = {
19 | open: false,
20 | }
21 | }
22 |
23 | componentWillMount() {
24 | const { dispatch, story, id } = this.props
25 | if (!story) {
26 | dispatch({ type: 'story/top/fetchOne', payload: id });
27 | }
28 | }
29 |
30 | handleClick = () => {
31 | this.setState({ open: !this.state.open })
32 | }
33 |
34 | render() {
35 | const { open } = this.state
36 | const { comment } = this.props
37 |
38 | if (!comment) {
39 | return null
40 | }
41 |
42 | let openLink = null
43 | let comments = null
44 |
45 | if (comment.kids && comment.kids.length > 0) {
46 | openLink = (
47 |
48 | |
49 | {open ? 'collapse ' : 'expand ' + pluralize(comment.kids.length)}
50 |
51 |
52 | )
53 | }
54 |
55 | if (open) {
56 | comments = (
57 |
58 | {comment.kids.map(id => )}
59 |
60 | )
61 | }
62 |
63 | return (
64 |
65 |
66 | {comment.by}
67 | {timeAgo(comment.time)} ago
68 | {openLink}
69 |
70 |
71 | {comments}
72 |
73 | )
74 | }
75 | }
76 |
77 | const ConnectedComment = connect((state, { id }) => ({
78 | comment: state.entity.story[id]
79 | }))(Comment)
80 |
81 | export default ConnectedComment;
82 |
--------------------------------------------------------------------------------
/src/components/Comment/style.css:
--------------------------------------------------------------------------------
1 | .comment-children .comment-children {
2 | margin-left: 1em;
3 | }
4 | .comment {
5 | border-top: 1px solid #eee;
6 | position: relative;
7 | }
8 | .comment .expand {
9 | cursor: pointer;
10 | }
11 | .comment .by,
12 | .comment .text {
13 | font-size: 0.9em;
14 | padding: 1em 0;
15 | }
16 | .comment .by {
17 | color: #999;
18 | padding-bottom: 0;
19 | }
20 | .comment .by a {
21 | color: #999;
22 | text-decoration: underline;
23 | }
24 | .comment .text a:hover {
25 | color: #f60;
26 | }
27 |
--------------------------------------------------------------------------------
/src/components/Placeholder/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import './style.css'
3 |
4 | export default function Placeholder() {
5 | return (
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 | )
18 | }
19 |
--------------------------------------------------------------------------------
/src/components/Placeholder/style.css:
--------------------------------------------------------------------------------
1 | @-webkit-keyframes placeHolderShimmer {
2 | 0% {
3 | background-position: -468px 0
4 | }
5 | 100% {
6 | background-position: 468px 0
7 | }
8 | }
9 |
10 | @keyframes placeHolderShimmer {
11 | 0% {
12 | background-position: -468px 0
13 | }
14 | 100% {
15 | background-position: 468px 0
16 | }
17 | }
18 |
19 | .timeline-item {
20 | background: #fff;
21 | padding: 20px 30px;
22 | margin: 0 auto;
23 | height: 83px;
24 | border-bottom: 1px solid #eee;
25 | }
26 |
27 | .animated-background {
28 | animation-duration: 4s;
29 | animation-fill-mode: forwards;
30 | animation-iteration-count: infinite;
31 | animation-name: placeHolderShimmer;
32 | animation-timing-function: linear;
33 | background: #f6f7f8;
34 | background: #eeeeee;
35 | background: linear-gradient(to right, #eeeeee 8%, #dddddd 18%, #eeeeee 33%);
36 | background-size: 800px 104px;
37 | height: 40px;
38 | position: relative;
39 | margin-top: 20px;
40 | }
41 |
42 | .background-masker {
43 | background: #fff;
44 | position: absolute;
45 | box-sizing: border-box;
46 | }
47 |
48 | .outlined .background-masker {
49 | border: 1px solid #ddd;
50 | }
51 |
52 | .outlined:hover .background-masker {
53 | border: none;
54 | }
55 |
56 | .outlined:hover .background-masker:hover {
57 | border: 1px solid #ccc;
58 | z-index: 1;
59 | }
60 |
61 | .background-masker.header-top,
62 | .background-masker.header-bottom,
63 | .background-masker.subheader-bottom {
64 | top: 0;
65 | left: 40px;
66 | right: 0;
67 | height: 10px;
68 | }
69 |
70 | .background-masker.header-left,
71 | .background-masker.subheader-left,
72 | .background-masker.header-right,
73 | .background-masker.subheader-right {
74 | top: 10px;
75 | left: 40px;
76 | height: 8px;
77 | width: 10px;
78 | }
79 |
80 | .background-masker.header-bottom {
81 | top: 18px;
82 | height: 6px;
83 | }
84 |
85 | .background-masker.subheader-left,
86 | .background-masker.subheader-right {
87 | top: 24px;
88 | height: 6px;
89 | }
90 |
91 | .background-masker.header-right,
92 | .background-masker.subheader-right {
93 | width: auto;
94 | left: 730px;
95 | right: 0;
96 | }
97 |
98 | .background-masker.subheader-right {
99 | left: 230px;
100 | }
101 |
102 | .background-masker.subheader-bottom {
103 | top: 30px;
104 | height: 10px;
105 | }
106 |
107 | .background-masker.content-top,
108 | .background-masker.content-second-line,
109 | .background-masker.content-third-line,
110 | .background-masker.content-second-end,
111 | .background-masker.content-third-end,
112 | .background-masker.content-first-end {
113 | top: 40px;
114 | left: 0;
115 | right: 0;
116 | height: 6px;
117 | }
118 |
--------------------------------------------------------------------------------
/src/components/index.js:
--------------------------------------------------------------------------------
1 | import Comment from './Comment'
2 | import Placeholder from './Placeholder'
3 |
4 | export {
5 | Comment,
6 | Placeholder,
7 | }
8 |
--------------------------------------------------------------------------------
/src/config/constants.js:
--------------------------------------------------------------------------------
1 | export const PAGE_SIZE = 20;
2 |
--------------------------------------------------------------------------------
/src/config/routes.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Route, IndexRedirect } from 'react-router';
3 | import App from '../containers/App';
4 | import * as Story from '../containers/Story'
5 | import * as User from '../containers/User'
6 |
7 | export default function routes() {
8 | return (
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/src/config/schemas.js:
--------------------------------------------------------------------------------
1 | import { Schema, arrayOf } from 'normalizr'
2 |
3 | const story = new Schema('story')
4 | const user = new Schema('user')
5 |
6 | export default {
7 | STORY: story,
8 | STORY_ARRAY: arrayOf(story),
9 | USER: user,
10 | }
11 |
--------------------------------------------------------------------------------
/src/configureStore.js:
--------------------------------------------------------------------------------
1 | import { createStore, applyMiddleware } from 'redux';
2 | import { createEpicMiddleware } from 'redux-observable';
3 | import createLogger from 'redux-logger';
4 | import { reducer, epic } from './modules/root';
5 |
6 | export default function configureStore() {
7 | const epicMiddleware = createEpicMiddleware(epic);
8 | const logger = createLogger();
9 |
10 | return createStore(
11 | reducer,
12 | applyMiddleware(epicMiddleware, logger)
13 | );
14 | }
15 |
--------------------------------------------------------------------------------
/src/containers/App/index.js:
--------------------------------------------------------------------------------
1 | import React, { PropTypes } from 'react';
2 | import TransitionGroup from 'react-addons-css-transition-group'
3 | import { Link } from 'react-router'
4 | import './style.css'
5 | import logo from './logo.png'
6 |
7 | const types = {
8 | top: 'Top',
9 | new: 'New',
10 | show: 'Show',
11 | ask: 'Ask',
12 | job: 'Jobs',
13 | }
14 |
15 | export default function App({ location, children }) {
16 | const links = Object.keys(types).map(key => {
17 | const path = `/${key}`
18 | const active = location.pathname.indexOf(path) === 0
19 | const className = active ? "router-link-active" : ""
20 | return {types[key]}
21 | })
22 |
23 | return (
24 |
25 |
36 |
41 | {children}
42 |
43 |
44 | );
45 | }
46 |
47 | App.propTypes = {
48 | children: PropTypes.element.isRequired,
49 | }
50 |
--------------------------------------------------------------------------------
/src/containers/App/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yesmeck/rx-hackernews/46dc9266725b29900812c5f4effa6e711c7c3d56/src/containers/App/logo.png
--------------------------------------------------------------------------------
/src/containers/App/logo.svg:
--------------------------------------------------------------------------------
1 |
8 |
--------------------------------------------------------------------------------
/src/containers/App/style.css:
--------------------------------------------------------------------------------
1 | body {
2 | font-family: Roboto, Helvetica, sans-serif;
3 | font-size: 15px;
4 | background-color: #f2f3f5;
5 | margin: 0;
6 | padding-top: 55px;
7 | color: #34495e;
8 | }
9 | a {
10 | color: #34495e;
11 | text-decoration: none;
12 | }
13 | .header {
14 | background-color: #f60;
15 | position: fixed;
16 | z-index: 999;
17 | top: 0;
18 | left: 0;
19 | right: 0;
20 | }
21 | .header .inner {
22 | max-width: 800px;
23 | box-sizing: border-box;
24 | margin: 0px auto;
25 | padding: 15px 5px;
26 | }
27 | .header a {
28 | color: rgba(255,255,255,0.8);
29 | line-height: 24px;
30 | transition: color 0.15s ease;
31 | display: inline-block;
32 | vertical-align: middle;
33 | font-weight: 300;
34 | letter-spacing: 0.075em;
35 | margin-right: 1.8em;
36 | }
37 | .header a:hover {
38 | color: #fff;
39 | }
40 | .header a.router-link-active {
41 | color: #fff;
42 | font-weight: 400;
43 | }
44 | .header a:nth-child(6) {
45 | margin-right: 0;
46 | }
47 | .header .github {
48 | color: #fff;
49 | font-size: 0.9em;
50 | margin: 0;
51 | float: right;
52 | }
53 | .logo {
54 | width: 24px;
55 | margin-right: 10px;
56 | display: inline-block;
57 | vertical-align: middle;
58 | }
59 | .view {
60 | padding-top: 50px;
61 | max-width: 800px;
62 | margin: 0 auto;
63 | position: relative;
64 | }
65 | .fade-enter-active,
66 | .fade-leave-active {
67 | transition: all 0.2s ease;
68 | }
69 | .fade-enter,
70 | .fade-leave-active {
71 | opacity: 0;
72 | }
73 | @media (max-width: 860px) {
74 | .header .inner {
75 | padding: 15px 30px;
76 | }
77 | }
78 | @media (max-width: 600px) {
79 | body {
80 | font-size: 14px;
81 | }
82 | .header .inner {
83 | padding: 15px;
84 | }
85 | .header a {
86 | margin-right: 1em;
87 | }
88 | .header .github {
89 | display: none;
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/src/containers/Story/DetailPage/Item/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Link } from 'react-router'
3 | import { host, timeAgo } from '../../../../helpers'
4 | import { Comment } from '../../../../components'
5 | import './style.css'
6 |
7 | export default function Item({ story }) {
8 | let title = null
9 | let hostSpan = null
10 | let comments = null
11 |
12 | if (story.url) {
13 | title = (
14 |
15 | {story.title}
16 |
17 | )
18 |
19 | hostSpan = (
20 |
21 | ({host(story.url)})
22 |
23 | )
24 | } else {
25 | title = {story.title}
26 | }
27 |
28 | if (story.kids) {
29 | comments = (
30 |
31 | {story.kids.map(id => )}
32 |
33 | )
34 | }
35 |
36 | return (
37 |
38 |
39 | {title}
40 | {hostSpan}
41 |
42 | {story.score} points
43 | | by {story.by}
44 | {timeAgo(story.time)} ago
45 |
46 |
47 |
48 |
49 | {story.kids ? story.descendants + ' comments' : 'No comments yet.'}
50 |
51 | {comments}
52 |
53 |
54 | )
55 | }
56 |
--------------------------------------------------------------------------------
/src/containers/Story/DetailPage/Item/style.css:
--------------------------------------------------------------------------------
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 | .item-view-header h1 {
7 | display: inline;
8 | font-size: 1.5em;
9 | margin: 0;
10 | margin-right: 0.5em;
11 | }
12 | .item-view-header .host,
13 | .item-view-header .meta,
14 | .item-view-header .meta a {
15 | color: #999;
16 | }
17 | .item-view-header .meta a {
18 | text-decoration: underline;
19 | }
20 | .item-view-comments {
21 | background-color: #fff;
22 | margin-top: 10px;
23 | padding: 0 2em;
24 | }
25 | .item-view-comments-header {
26 | margin: 0;
27 | font-size: 1.1em;
28 | padding: 1em 0;
29 | }
30 | .comment-children {
31 | list-style-type: none;
32 | padding: 0;
33 | margin: 0;
34 | }
35 | @media (max-width: 600px) {
36 | .item-view-header h1 {
37 | font-size: 1.25em;
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/src/containers/Story/DetailPage/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import { connect } from 'react-redux'
3 | import Item from './Item'
4 |
5 | class DetailPage extends Component {
6 | componentWillMount() {
7 | const { dispatch, story, params: { id } } = this.props
8 | if (!story) {
9 | dispatch({ type: 'story/top/fetchOne', payload: id });
10 | }
11 | }
12 |
13 | render () {
14 | const { story } = this.props
15 |
16 | return story ? : null
17 | }
18 | }
19 |
20 | export default connect((state, { params }) => ({
21 | story: state.entity.story[params.id]
22 | }))(DetailPage)
23 |
--------------------------------------------------------------------------------
/src/containers/Story/ListPage/List/Item/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Link } from 'react-router'
3 | import { host, timeAgo } from '../../../../../helpers'
4 | import './style.css'
5 |
6 | export default function Item({ story }) {
7 | let title = null
8 | let author = null
9 | let commentLink = null
10 | let label = null
11 |
12 | if (story.url) {
13 | title = (
14 |
15 | {story.title}
16 | ({host(story.url)})
17 |
18 | )
19 | } else {
20 | title = (
21 |
22 | {story.title}
23 |
24 | )
25 | }
26 |
27 | if (story.type !== 'job') {
28 | author = (
29 |
30 | by {story.by}
31 |
32 | )
33 |
34 | commentLink = (
35 |
36 | | {story.descendants} comments
37 |
38 | )
39 | }
40 |
41 | if (story.type !== 'story') {
42 | label = {story.type}
43 | }
44 |
45 | return (
46 |
47 | {story.score}
48 | {title}
49 |
50 |
51 | {author}
52 |
53 | {timeAgo(story.time)} ago
54 |
55 | {commentLink}
56 |
57 | {label}
58 |
59 | )
60 | }
61 |
--------------------------------------------------------------------------------
/src/containers/Story/ListPage/List/Item/style.css:
--------------------------------------------------------------------------------
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 | .news-item .score {
9 | color: #f60;
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 | .news-item .meta,
20 | .news-item .host {
21 | font-size: 0.85em;
22 | color: #999;
23 | }
24 | .news-item .meta a,
25 | .news-item .host a {
26 | color: #999;
27 | text-decoration: underline;
28 | }
29 | .news-item .meta a:hover,
30 | .news-item .host a:hover {
31 | color: #f60;
32 | }
33 |
--------------------------------------------------------------------------------
/src/containers/Story/ListPage/List/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import TransitionGroup from 'react-addons-css-transition-group'
3 | import { Link } from 'react-router'
4 | import { Placeholder } from '../../../../components'
5 | import Item from './Item'
6 | import './style.css'
7 |
8 | export default class List extends Component {
9 | constructor(props) {
10 | super(props)
11 |
12 | this.page = +(props.page || 1)
13 | this.transition = 'slide-left'
14 | }
15 |
16 | componentWillReceiveProps(nextProps) {
17 | const nextPage = +(nextProps.page || 1)
18 | if (nextPage > this.page) {
19 | this.transition = 'slide-left'
20 | } else {
21 | this.transition = 'slide-right'
22 | }
23 | this.page = nextPage
24 | }
25 |
26 | render() {
27 | const { type, stories, maxPage, loading } = this.props
28 |
29 | let prev = null
30 | let more = null
31 | let items = null
32 |
33 |
34 | if (this.page > 1) {
35 | prev = < prev
36 | } else {
37 | prev = < prev
38 | }
39 |
40 | if (this.page < maxPage) {
41 | more = more >
42 | } else {
43 | more = more >
44 | }
45 |
46 | if (stories.length <= 0 && loading) {
47 | items = (
48 |
49 | {Array(20).fill(1).map((_, i) => )}
50 |
51 | )
52 | } else {
53 | items = (
54 |
60 | {stories.map(story => )}
61 |
62 | )
63 | }
64 |
65 | return (
66 |
67 |
68 | {prev}
69 | {this.page || 1}/{maxPage}
70 | {more}
71 |
72 |
77 |
78 | {items}
79 |
80 |
81 |
82 | )
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/src/containers/Story/ListPage/List/style.css:
--------------------------------------------------------------------------------
1 | .news-list-nav,
2 | .news-list {
3 | background-color: #fff;
4 | border-radius: 2px;
5 | }
6 | .news-list-nav {
7 | padding: 15px 30px;
8 | position: fixed;
9 | text-align: center;
10 | top: 55px;
11 | left: 0;
12 | right: 0;
13 | z-index: 998;
14 | box-shadow: 0 1px 2px rgba(0,0,0,0.1);
15 | }
16 | .news-list-nav a {
17 | margin: 0 1em;
18 | }
19 | .news-list-nav .disabled {
20 | color: #ccc;
21 | }
22 | .news-list {
23 | position: absolute;
24 | margin: 30px 0;
25 | width: 100%;
26 | transition: all 0.5s cubic-bezier(0.55, 0, 0.1, 1);
27 | }
28 | .news-list ul {
29 | list-style-type: none;
30 | padding: 0;
31 | margin: 0;
32 | }
33 | .slide-left-enter,
34 | .slide-right-leave-active {
35 | opacity: 0;
36 | transform: translate(30px, 0);
37 | }
38 | .slide-left-leave-active,
39 | .slide-right-enter {
40 | opacity: 0;
41 | transform: translate(-30px, 0);
42 | }
43 | .item-move,
44 | .item-enter-active,
45 | .item-leave-active {
46 | transition: all 0.5s cubic-bezier(0.55, 0, 0.1, 1);
47 | }
48 | .item-enter {
49 | opacity: 0;
50 | transform: translate(30px, 0);
51 | }
52 | .item-leave-active {
53 | position: absolute;
54 | opacity: 0;
55 | transform: translate(30px, 0);
56 | }
57 | @media (max-width: 600px) {
58 | .news-list {
59 | margin: 10px 0;
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/src/containers/Story/ListPage/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import { connect } from 'react-redux'
3 | import * as story from '../../../modules/story'
4 | import List from './List'
5 | import { PAGE_SIZE } from '../../../config/constants';
6 |
7 | export default function createListPage(type) {
8 | class ListPage extends Component {
9 | componentWillMount() {
10 | this.props.dispatch({ type: `story/${type}/watch` })
11 | }
12 |
13 | componentWillUnmount() {
14 | this.props.dispatch({ type: `story/${type}/cancelWatch` })
15 | }
16 |
17 | render() {
18 | const { stories = [], maxPage, loading, params: { page } } = this.props
19 |
20 | return (
21 |
28 | )
29 | }
30 | }
31 |
32 | return connect((state, props) => ({
33 | stories: story.selectList(state, type, props.params.page),
34 | maxPage: Math.ceil(state.story[type].ids.length / PAGE_SIZE),
35 | loading: state.story[type].loading,
36 | }))(ListPage)
37 | }
38 |
--------------------------------------------------------------------------------
/src/containers/Story/index.js:
--------------------------------------------------------------------------------
1 | import DetailPage from './DetailPage'
2 | import createListPage from './ListPage'
3 |
4 | export {
5 | DetailPage,
6 | createListPage,
7 | }
8 |
--------------------------------------------------------------------------------
/src/containers/User/DetailPage/Item/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { timeAgo } from '../../../../helpers'
3 | import './style.css'
4 |
5 | export default function Item({ user }) {
6 | let about = null
7 |
8 | if (user.about) {
9 | about = (
10 |
11 | )
12 | }
13 |
14 | return (
15 |
16 |
17 |
User : {user.id}
18 |
19 | - Created: {timeAgo(user.created)} ago
20 | - Karma: {user.karma}
21 | {about}
22 |
23 |
24 | submissions |
25 | comments
26 |
27 |
28 |
29 | )
30 | }
31 |
--------------------------------------------------------------------------------
/src/containers/User/DetailPage/Item/style.css:
--------------------------------------------------------------------------------
1 | .user-view {
2 | background-color: #fff;
3 | box-sizing: border-box;
4 | padding: 2em 3em;
5 | }
6 | .user-view h1 {
7 | margin: 0;
8 | font-size: 1.5em;
9 | }
10 | .user-view .meta {
11 | list-style-type: none;
12 | padding: 0;
13 | }
14 | .user-view .label {
15 | display: inline-block;
16 | min-width: 4em;
17 | }
18 | .user-view .about {
19 | margin: 1em 0;
20 | }
21 | .user-view .links a {
22 | text-decoration: underline;
23 | }
24 |
--------------------------------------------------------------------------------
/src/containers/User/DetailPage/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import { connect } from 'react-redux'
3 | import Item from './Item'
4 |
5 | class DetailPage extends Component {
6 | componentWillMount() {
7 | const { dispatch, user, params: { id } } = this.props
8 | if (!user) {
9 | dispatch({ type: 'user/fetch', payload: id });
10 | }
11 | }
12 |
13 | render() {
14 | const { user } = this.props
15 |
16 | return (
17 | user ? : null
18 | )
19 | }
20 | }
21 |
22 | export default connect((state, { params }) => ({
23 | user: state.entity.user[params.id]
24 | }))(DetailPage)
25 |
--------------------------------------------------------------------------------
/src/containers/User/index.js:
--------------------------------------------------------------------------------
1 | import DetailPage from './DetailPage'
2 |
3 | export {
4 | DetailPage,
5 | }
6 |
--------------------------------------------------------------------------------
/src/helpers/index.js:
--------------------------------------------------------------------------------
1 | export function host(url) {
2 | const host = url.replace(/^https?:\/\//, '').replace(/\/.*$/, '')
3 | const parts = host.split('.').slice(-3)
4 | if (parts[0] === 'www') parts.shift()
5 | return parts.join('.')
6 | }
7 |
8 | export function timeAgo(time) {
9 | const between = Date.now() / 1000 - Number(time)
10 | if (between < 3600) {
11 | return pluralize(~~(between / 60), ' minute')
12 | } else if (between < 86400) {
13 | return pluralize(~~(between / 3600), ' hour')
14 | } else {
15 | return pluralize(~~(between / 86400), ' day')
16 | }
17 | }
18 |
19 | export function pluralize(time, label) {
20 | if (time === 1) {
21 | return time + label
22 | }
23 | return time + label + 's'
24 | }
25 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import { Provider } from 'react-redux';
4 | import { Router, browserHistory } from 'react-router';
5 | import configureStore from './configureStore';
6 | import routes from './config/routes';
7 |
8 | const store = configureStore();
9 |
10 | ReactDOM.render(
11 |
12 |
13 | ,
14 | document.getElementById('root')
15 | );
16 |
--------------------------------------------------------------------------------
/src/modules/entity.js:
--------------------------------------------------------------------------------
1 | import merge from 'lodash/fp/merge';
2 |
3 | const initialState = {
4 | story: {
5 | top: {},
6 | new: {},
7 | show: {},
8 | ask: {},
9 | job: {},
10 | },
11 | user: {},
12 | };
13 |
14 | export const reducer = (state = initialState, { type, payload }) => {
15 | switch (type) {
16 | case 'entity/set':
17 | return merge(state, payload.entities)
18 | default:
19 | return state;
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/modules/root.js:
--------------------------------------------------------------------------------
1 | import { combineReducers } from 'redux';
2 | import { combineEpics } from 'redux-observable';
3 | import * as entity from './entity';
4 | import * as story from './story';
5 | import * as user from './user';
6 |
7 | export const reducer = combineReducers({
8 | entity: entity.reducer,
9 | story: combineReducers({
10 | top: story.createReducer('top'),
11 | new: story.createReducer('new'),
12 | show: story.createReducer('show'),
13 | ask: story.createReducer('ask'),
14 | job: story.createReducer('job'),
15 | }),
16 | user: user.reducer,
17 | });
18 |
19 | export const epic = combineEpics(
20 | story.createEpic('top'),
21 | story.createEpic('new'),
22 | story.createEpic('show'),
23 | story.createEpic('ask'),
24 | story.createEpic('job'),
25 | user.epic,
26 | );
27 |
--------------------------------------------------------------------------------
/src/modules/story.js:
--------------------------------------------------------------------------------
1 | import 'rxjs';
2 | import { Observable } from 'rxjs/Rx';
3 | import { normalize } from 'normalizr';
4 | import { combineEpics } from 'redux-observable';
5 | import { createSelector } from 'reselect';
6 | import Schemas from '../config/schemas';
7 | import { watchIdsByType, fetchItems, fetchItem } from '../services/db';
8 | import { PAGE_SIZE } from '../config/constants';
9 |
10 | const initialState = {
11 | loading: false,
12 | ids: [],
13 | };
14 |
15 | export const createReducer = type =>
16 | (state = initialState, { type: actionType, payload}) => {
17 | switch (actionType) {
18 | case `story/${type}/watch`:
19 | return {
20 | ...state,
21 | loading: true,
22 | };
23 | case `story/${type}/setIds`:
24 | return {
25 | ...state,
26 | loading: false,
27 | ids: payload,
28 | };
29 | default:
30 | return state;
31 | }
32 | return state;
33 | }
34 |
35 | const watchEpic = type => action$ =>
36 | action$
37 | .ofType(`story/${type}/watch`)
38 | .mergeMap(() =>
39 | Observable.bindCallback(watchIdsByType)(type)
40 | .takeUntil(action$.ofType(`story/${type}/cancelWatch`))
41 | )
42 | .mergeMap(ids => Observable.from(fetchItems(ids)))
43 | .map(stories => normalize(stories, Schemas.STORY_ARRAY))
44 | .mergeMap(normalized =>
45 | Observable.concat(
46 | Observable.of({
47 | type: 'entity/set',
48 | payload: normalized
49 | }),
50 | Observable.of({
51 | type: `story/${type}/setIds`,
52 | payload: normalized.result
53 | }),
54 | )
55 | );
56 |
57 | const fetchOne = type => action$ =>
58 | action$
59 | .ofType(`story/${type}/fetchOne`)
60 | .mergeMap(({ payload }) => Observable.from(fetchItem(payload)))
61 | .map(story => normalize(story, Schemas.STORY))
62 | .map(normalized => ({ type: 'entity/set', payload: normalized }))
63 |
64 | export const createEpic = type => combineEpics(
65 | watchEpic(type),
66 | fetchOne(type),
67 | )
68 |
69 | export const selectList = createSelector(
70 | state => state.entity.story,
71 | (state, type, page = 1) => {
72 | const start = (page - 1) * PAGE_SIZE
73 | const end = page * PAGE_SIZE
74 | return state.story[type].ids.slice(start, end)
75 | },
76 | (stories, ids) => ids.map(id => stories[id])
77 | );
78 |
--------------------------------------------------------------------------------
/src/modules/user.js:
--------------------------------------------------------------------------------
1 | import 'rxjs';
2 | import { Observable } from 'rxjs/Rx';
3 | import { normalize } from 'normalizr'
4 | import { combineEpics } from 'redux-observable';
5 | import Schemas from '../config/schemas'
6 | import { fetchUser } from '../services/db'
7 |
8 | const initialState = {};
9 |
10 | export const reducer = (state = initialState, action) => {
11 | return state;
12 | }
13 |
14 | const fetchEpic = action$ =>
15 | action$
16 | .ofType('user/fetch')
17 | .mergeMap(({ payload }) => Observable.from(fetchUser(payload)))
18 | .map(user => normalize(user, Schemas.USER))
19 | .map(normalized => ({ type: 'entity/set', payload: normalized }))
20 |
21 |
22 | export const epic = combineEpics(
23 | fetchEpic
24 | );
25 |
--------------------------------------------------------------------------------
/src/services/db.js:
--------------------------------------------------------------------------------
1 | import firebase from 'firebase'
2 |
3 | const db = firebase.initializeApp({
4 | databaseURL: 'https://hacker-news.firebaseio.com',
5 | }).database()
6 |
7 | function fetch(path) {
8 | return new Promise(resolve => {
9 | db.ref(`/v0/${path}`).once('value', snapshot => {
10 | resolve(snapshot.val())
11 | })
12 | })
13 | }
14 |
15 | function watch(path, cb) {
16 | const ref = db.ref(`/v0/${path}`)
17 | const handler = snapshot => {
18 | cb(snapshot.val())
19 | }
20 | ref.on('value', handler)
21 | return () => {
22 | ref.off('value', handler)
23 | }
24 | }
25 |
26 | export function watchIdsByType(type, cb) {
27 | return watch(`${type}stories`, cb)
28 | }
29 |
30 | export function fetchIdsByType(type) {
31 | return fetch(`${type}stories`)
32 | }
33 |
34 | export function fetchItem(id) {
35 | return fetch(`item/${id}`)
36 | }
37 |
38 | export function fetchItems(ids) {
39 | return Promise.all(ids.map(id => fetchItem(id)))
40 | }
41 |
42 | export function fetchUser(id) {
43 | return fetch(`user/${id}`)
44 | }
45 |
--------------------------------------------------------------------------------