├── .gitignore ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── public ├── favicon.ico ├── index.html └── manifest.json └── src ├── App.css ├── App.js ├── App.test.js ├── api.js ├── components ├── NavBar │ ├── Brand.js │ ├── Link.js │ ├── Menu.js │ ├── MenuButton.js │ └── index.js └── Story │ ├── Footer.js │ ├── Header.js │ ├── Styles.js │ └── index.js ├── containers └── StoryList.js ├── index.css ├── index.js ├── logo.svg ├── registerServiceWorker.js └── utils ├── helper.js └── media.js /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://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.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Andreas Reiterer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This project is a ReactJS client for HackerNews stories. Since the [API](https://github.com/HackerNews/API) only provides read access 2 | it display the newest stories 3 | 4 | ## Roadmap 5 | * [x] List of stories (static) 6 | * [x] Get the content from HackerNews API 7 | * [ ] Paging 8 | * [ ] Show discussions/comments 9 | 10 | ## Try out 11 | If you want to try it out, just clone the repository and use 12 | 13 | ``` 14 | npm -i 15 | npm start 16 | ``` 17 | 18 | ## Libraries used 19 | * [styled-components](https://www.styled-components.com) 20 | * [re-base](https://github.com/tylermcginnis/re-base) 21 | * [firebase](https://github.com/firebase/) 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hackernewsclone", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "firebase": "^4.8.1", 7 | "prop-types": "^15.6.0", 8 | "re-base": "^3.2.1", 9 | "react": "^16.2.0", 10 | "react-dom": "^16.2.0", 11 | "react-spinkit": "^3.0.0", 12 | "styled-components": "^2.3.3" 13 | }, 14 | "devDependencies": { 15 | "react-scripts": "1.0.10" 16 | }, 17 | "scripts": { 18 | "start": "react-scripts start", 19 | "build": "react-scripts build", 20 | "test": "react-scripts test --env=jsdom", 21 | "eject": "react-scripts eject" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/areiterer/hackernews-client/9be9cd70682aecb1c58a14f62fbeafe27c3a8fd3/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 14 | 23 | HackerNews Clone 24 | 25 | 26 | 27 | 30 |
31 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | } 10 | ], 11 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | animation: App-logo-spin infinite 20s linear; 7 | height: 80px; 8 | } 9 | 10 | .App-header { 11 | background-color: #222; 12 | height: 150px; 13 | padding: 20px; 14 | color: white; 15 | } 16 | 17 | .App-intro { 18 | font-size: large; 19 | } 20 | 21 | @keyframes App-logo-spin { 22 | from { transform: rotate(0deg); } 23 | to { transform: rotate(360deg); } 24 | } 25 | -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import { injectGlobal } from "styled-components"; 3 | 4 | import Api from "./api"; 5 | import StoryList from "./containers/StoryList"; 6 | import NavBar from "./components/NavBar"; 7 | 8 | // eslint-disable-next-line no-unused-expressions 9 | injectGlobal` 10 | @font-face { 11 | font-family: 'Verdana, Geneva, sans-serif' 12 | } 13 | body { 14 | margin: 0; 15 | } 16 | `; 17 | 18 | function fetchSingleStory(id, index) { 19 | const rank = index + 1; 20 | return new Promise(resolve => { 21 | Api.fetch(`/item/${id}`, { 22 | then(data) { 23 | let item = data; 24 | // add the rank since it does not exist yet 25 | item.rank = rank; 26 | resolve(item); 27 | } 28 | }); 29 | }); 30 | } 31 | 32 | class App extends Component { 33 | state = { 34 | newStories: [] 35 | }; 36 | 37 | fetchNewStories(storyIds) { 38 | let actions = storyIds.slice(0, 30).map(fetchSingleStory); 39 | let results = Promise.all(actions); 40 | results.then(data => 41 | this.setState( 42 | Object.assign({}, this.state, { 43 | newStories: data 44 | }) 45 | ) 46 | ); 47 | } 48 | 49 | componentDidMount() { 50 | Api.fetch(`/newstories`, { 51 | context: this, 52 | then(storyIds) { 53 | this.fetchNewStories(storyIds); 54 | } 55 | }); 56 | } 57 | 58 | render() { 59 | return ( 60 |
61 | 62 | 63 |
64 | ); 65 | } 66 | } 67 | 68 | export default App; 69 | -------------------------------------------------------------------------------- /src/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | 5 | it('renders without crashing', () => { 6 | const div = document.createElement('div'); 7 | ReactDOM.render(, div); 8 | }); 9 | -------------------------------------------------------------------------------- /src/api.js: -------------------------------------------------------------------------------- 1 | import rebase from "re-base"; 2 | import firebase from "@firebase/app"; 3 | import "@firebase/database"; 4 | 5 | const HN_DATABASE_URL = "https://hacker-news.firebaseio.com"; 6 | const HN_VERSION = "v0"; 7 | 8 | firebase.initializeApp({ databaseURL: HN_DATABASE_URL }); 9 | let db = firebase.database(); 10 | let base = rebase.createClass(db); 11 | 12 | // Api is a wrapper around base, to include the version child path to the binding automatically. 13 | const Api = { 14 | /** 15 | * One way data binding from Firebase to a component's state. 16 | * @param {string} endpoint 17 | * @param {object} options 18 | * @return {object} An object which you can pass to 'removeBinding' if you want to remove the 19 | * listener while the component is still mounted. 20 | */ 21 | bindToState(endpoint, options) { 22 | return base.bindToState(`/${HN_VERSION}${endpoint}`, options); 23 | }, 24 | 25 | /** 26 | * Listens to a Firebase endpoint without binding changes to a state property. Instead a callback 27 | * will be invoked. 28 | * 29 | * @param {string} endpoint 30 | * @param {object} options 31 | * @returns 32 | */ 33 | listenTo(endpoint, options) { 34 | return base.listenTo(`/${HN_VERSION}${endpoint}`, options); 35 | }, 36 | 37 | /** 38 | * Retrieves data from Firebase once without setting up binding 39 | * @param {string} endpoint 40 | * @param {object} options 41 | * @return {Promise} A Firebase Promise which resolves when the write is complete and rejects 42 | * if there is an error. 43 | */ 44 | fetch(endpoint, options) { 45 | return base.fetch(`/${HN_VERSION}${endpoint}`, options); 46 | } 47 | }; 48 | 49 | export default Api; 50 | -------------------------------------------------------------------------------- /src/components/NavBar/Brand.js: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | import { StyledLink } from "./Link"; 4 | 5 | const Brand = styled(StyledLink)` 6 | float:left; 7 | font-size: 16px; 8 | font-weight: bold; 9 | 10 | padding: 11px 14px; 11 | 12 | margin-right: 20px; 13 | 14 | &:hover { 15 | color: #f2f2f2; 16 | } 17 | `; 18 | 19 | export default Brand; 20 | -------------------------------------------------------------------------------- /src/components/NavBar/Link.js: -------------------------------------------------------------------------------- 1 | import styled, { css } from "styled-components"; 2 | import { desktop } from "../../utils/media"; 3 | 4 | export const StyledLink = styled.a` 5 | display: inline-block; 6 | color: #f2f2f2; 7 | text-align: center; 8 | 9 | padding: 12px 14px; 10 | 11 | text-decoration: none; 12 | font-size: 14px; 13 | 14 | ${desktop(css` 15 | &:hover { 16 | color: #fff; 17 | }`)}; 18 | `; 19 | -------------------------------------------------------------------------------- /src/components/NavBar/Menu.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled, { css } from "styled-components"; 3 | import { desktop, maxTablet } from "../../utils/media"; 4 | 5 | import { StyledLink } from "./Link"; 6 | 7 | const MenuWrapper = styled.div` 8 | overflow: hidden; 9 | display: none; 10 | 11 | ${desktop(css` 12 | display:block; 13 | `)}; 14 | 15 | ${props => 16 | maxTablet( 17 | props.isHidden || 18 | css` 19 | display:block; 20 | width: 100%; 21 | margin-top: 40px; 22 | ` 23 | )}; 24 | `; 25 | const MenuItem = styled(StyledLink)` 26 | float: left; 27 | 28 | ${props => 29 | maxTablet( 30 | props.isHidden || 31 | css` 32 | float: none; 33 | display:block; 34 | text-align: left; 35 | 36 | font-size:12px; 37 | letter-spacing: 1px; 38 | color: #333; 39 | background-color: #f6f6ef; 40 | border-bottom: 1px solid rgba(255,255,255,0.75); 41 | 42 | &:active { 43 | background-color: #e6e6e6; 44 | font-weight: bold; 45 | } 46 | ` 47 | )}; 48 | `; 49 | 50 | function Menu(props) { 51 | const children = props.children; 52 | 53 | return ( 54 | 55 | {React.Children.map(children, child => 56 | React.cloneElement(child, { isHidden: props.isHidden }) 57 | )} 58 | 59 | ); 60 | } 61 | 62 | export default Menu; 63 | export { MenuItem }; 64 | -------------------------------------------------------------------------------- /src/components/NavBar/MenuButton.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled, { css } from "styled-components"; 3 | import { desktop } from "../../utils/media"; 4 | 5 | import { StyledLink } from "./Link"; 6 | 7 | const MenuButton = styled(StyledLink).attrs({ 8 | height: null, 9 | width: null 10 | })` 11 | height: 16px; 12 | position: absolute; 13 | right: 0; 14 | top: 0; 15 | display: block; 16 | line-height: 14px; 17 | cursor: pointer; 18 | 19 | &:hover { 20 | background: none; 21 | color: #f2f2f2; 22 | } 23 | 24 | ${desktop(css` 25 | display: none; 26 | `)}; 27 | `; 28 | 29 | export default props => ; 30 | -------------------------------------------------------------------------------- /src/components/NavBar/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import styled from "styled-components"; 3 | 4 | import Menu, { MenuItem } from "./Menu"; 5 | import MenuButton from "./MenuButton"; 6 | import Brand from "./Brand"; 7 | 8 | const Wrapper = styled.div` 9 | background-color: #333; 10 | overflow: hidden; 11 | box-shadow: 5px 1px 5px #888888; 12 | `; 13 | 14 | class NavBar extends Component { 15 | state = { 16 | isMenuHidden: true 17 | }; 18 | 19 | onToggleMenu = () => { 20 | this.setState((prevState, props) => { 21 | return { isMenuHidden: !prevState.isMenuHidden }; 22 | }); 23 | }; 24 | 25 | render() { 26 | return ( 27 | 28 | HNews 29 | 30 | {/* News */} 31 | 32 | 33 | 34 | ); 35 | } 36 | } 37 | 38 | export default NavBar; 39 | -------------------------------------------------------------------------------- /src/components/Story/Footer.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | 4 | import { FooterWrapper, FooterLink } from "./Styles"; 5 | import * as utils from "../../utils/helper"; 6 | 7 | export default function Footer(props) { 8 | const userUrl = utils.getUserUrl(props.username); 9 | const itemUrl = utils.getItemUrl(props.itemId); 10 | 11 | return ( 12 | 13 | {props.score} point by 14 | {props.username} 15 | | 16 | {new Date(props.timestamp * 1000).toDateString()} 17 | | 18 | view on HackerNews 19 | 20 | ); 21 | } 22 | 23 | Footer.propTypes = { 24 | username: PropTypes.string.isRequired, 25 | itemId: PropTypes.number.isRequired, 26 | score: PropTypes.number.isRequired, 27 | timestamp: PropTypes.number.isRequired 28 | }; 29 | -------------------------------------------------------------------------------- /src/components/Story/Header.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import * as utils from "../../utils/helper"; 4 | 5 | import { 6 | HeaderWrapper, 7 | RankContainer, 8 | Title, 9 | SourceContainer, 10 | SourceLink 11 | } from "./Styles"; 12 | 13 | export default function Header(props) { 14 | let url = props.url; 15 | let sourceUrl; 16 | 17 | if (!url) { 18 | url = utils.getItemUrl(props.itemId); 19 | } 20 | 21 | sourceUrl = utils.getSourceUrl(url); 22 | 23 | return ( 24 | 25 | 26 | {props.rank}. 27 | 28 | 29 | {props.title} 30 | 31 | 32 | ({sourceUrl}) 33 | 34 | 35 | ); 36 | } 37 | 38 | Header.propTypes = { 39 | itemId: PropTypes.number.isRequired, 40 | rank: PropTypes.number.isRequired, 41 | title: PropTypes.string.isRequired, 42 | url: PropTypes.string 43 | }; 44 | -------------------------------------------------------------------------------- /src/components/Story/Styles.js: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | export const Wrapper = styled.div` 4 | background-color: #f6f6ef; 5 | padding: 8px; 6 | margin: 8px; 7 | 8 | box-shadow: 1px 1px 5px #888888; 9 | `; 10 | 11 | export const HeaderWrapper = styled.div` 12 | display: flex; 13 | flex-direction: row; 14 | font-size: 10pt; 15 | `; 16 | 17 | export const RankContainer = styled.div` 18 | margin: 0px 5px; 19 | color: #828282; 20 | `; 21 | 22 | export const Title = styled.a.attrs({ 23 | target: "_blank", 24 | rel: "noopener" 25 | })` 26 | color: #000000; 27 | text-decoration: none; 28 | margin: 0px 2px; 29 | 30 | &:visited { 31 | color: #828282; 32 | } 33 | `; 34 | 35 | export const SourceContainer = styled.span` 36 | font-size: 10px; 37 | margin-top: 1px; 38 | color: #828282; 39 | `; 40 | 41 | export const SourceLink = styled.a.attrs({ 42 | target: "_blank", 43 | rel: "noopener" 44 | })` 45 | color: #828282; 46 | text-decoration: none; 47 | margin: 0px 1px; 48 | `; 49 | 50 | export const FooterWrapper = styled.div` 51 | padding: 4px 0px 0px 24px; 52 | 53 | font-size: 7pt; 54 | color: #828282; 55 | 56 | margin: 0; 57 | `; 58 | 59 | export const FooterLink = styled.a.attrs({ 60 | target: "_blank", 61 | rel: "noopener" 62 | })` 63 | color: #828282; 64 | text-decoration: none; 65 | margin: 0px 3px; 66 | `; 67 | -------------------------------------------------------------------------------- /src/components/Story/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import PropTypes from "prop-types"; 3 | 4 | import { Wrapper } from "./Styles"; 5 | import Header from "./Header"; 6 | import Footer from "./Footer"; 7 | 8 | export default class Story extends Component { 9 | render() { 10 | const item = this.props.item; 11 | 12 | return ( 13 | 14 |
20 |