├── .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 | 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 |
    26 |
    27 | 28 | logo 29 | 30 | {links} 31 | 32 | Source 33 | 34 |
    35 |
    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 | 2 | 3 | 4 | 5 | 6 | 7 | 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 | --------------------------------------------------------------------------------