├── .nvmrc ├── CNAME ├── public ├── favicon.ico ├── manifest.json └── index.html ├── src ├── utils │ ├── constants.js │ ├── helper.js │ ├── helper.test.js │ ├── Api.js │ └── Api.test.js ├── index.js ├── components │ ├── App.test.js │ ├── 404Page.js │ ├── CustomLoader.js │ ├── Job.js │ ├── Story.js │ ├── Item.js │ ├── App.js │ ├── NavBar.js │ └── Stories.js └── index.css ├── CONTRIBUTING.md ├── .editorconfig ├── .eslintrc ├── .gitignore ├── .travis.yml ├── README.md ├── package.json └── LICENSE /.nvmrc: -------------------------------------------------------------------------------- 1 | lts/* 2 | -------------------------------------------------------------------------------- /CNAME: -------------------------------------------------------------------------------- 1 | hn.armujahid.me 2 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/armujahid/hnreact/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /src/utils/constants.js: -------------------------------------------------------------------------------- 1 | export const API_URL = 'https://hacker-news.firebaseio.com/v0/' 2 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to contribute 2 | 3 | Log any issue using issues section of github. 4 | You can also fork it and send pull requests. 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | indent_style = space 7 | indent_size = 2 8 | trim_trailing_whitespace = true 9 | charset = utf-8 10 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["eslint:recommended", "plugin:react/recommended", "plugin:jest/recommended"], 3 | "parser": "babel-eslint", 4 | "env": { 5 | "browser": true, 6 | "es6": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/utils/helper.js: -------------------------------------------------------------------------------- 1 | export const handleClick = (event, type, id) => { 2 | window.open(`https://news.ycombinator.com/${type}?id=${id}`, "_blank") 3 | event.preventDefault() 4 | event.stopPropagation() 5 | } 6 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | import App from './components/App'; 5 | import 'bootstrap/dist/css/bootstrap.min.css'; 6 | 7 | ReactDOM.render(, document.getElementById('root')); 8 | -------------------------------------------------------------------------------- /src/components/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 | ReactDOM.unmountComponentAtNode(div); 9 | }); 10 | -------------------------------------------------------------------------------- /src/components/404Page.js: -------------------------------------------------------------------------------- 1 | import React, { Fragment } from 'react'; 2 | import { NavLink } from 'react-router-dom' 3 | 4 | const NotFound = () => { 5 | return ( 6 | 7 |
404 Not Found
8 | Click here to go back to home page 9 |
10 | ); 11 | } 12 | 13 | export default NotFound 14 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /src/utils/helper.test.js: -------------------------------------------------------------------------------- 1 | import { handleClick } from './helper' 2 | 3 | it('handleClick', () => { 4 | global.open = jest.fn(); 5 | const event = { 6 | preventDefault: jest.fn(), 7 | stopPropagation: jest.fn() 8 | } 9 | handleClick(event, 'xyz', '2'); 10 | expect(global.open).toBeCalledWith(`https://news.ycombinator.com/xyz?id=2`, "_blank") 11 | expect(event.preventDefault).toBeCalled() 12 | expect(event.stopPropagation).toBeCalled() 13 | }); 14 | -------------------------------------------------------------------------------- /src/components/CustomLoader.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ContentLoader from "react-content-loader" 3 | import { Card } from 'reactstrap'; 4 | 5 | const CustomLoader = props => ( 6 | 7 | 15 | 16 | 17 | 18 | 19 | ) 20 | 21 | export default CustomLoader 22 | -------------------------------------------------------------------------------- /src/components/Job.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Card, CardBody, CardTitle} from 'reactstrap'; 3 | import { handleClick } from '../utils/helper' 4 | 5 | const Job = ({ item }) => { 6 | return ( 7 | item.url? window.open(item.url, "_blank"): null}> 8 | 9 | {item.title} 10 |

{item.score} points by handleClick(event, 'user', item.by)}>{item.by} | handleClick(event, 'item', item.id)}>view full description

11 |
12 |
13 | ) 14 | } 15 | 16 | export default Job 17 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: sans-serif; 5 | overflow-y: scroll; 6 | } 7 | 8 | .main-container { 9 | margin-top: 3px; 10 | } 11 | 12 | .container { 13 | max-width: 1000px; 14 | margin: 0 auto; 15 | padding: 10px; 16 | } 17 | 18 | .navbar-nav li a { 19 | line-height: 40px; 20 | } 21 | 22 | .card { 23 | cursor: pointer; 24 | margin-bottom: 10px; 25 | } 26 | 27 | div.card { 28 | box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19); 29 | } 30 | 31 | [class*=col-] { 32 | padding: 15px; 33 | } 34 | 35 | .card-text { 36 | font-size: .8rem; 37 | color: #666; 38 | margin: 0; 39 | } 40 | 41 | -------------------------------------------------------------------------------- /src/utils/Api.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import { API_URL } from './constants' 3 | 4 | export async function getItem(id) { 5 | try { 6 | const itemResonse = await axios.get(`${API_URL}/item/${id}.json`) 7 | return { resolved: true, data: itemResonse.data } 8 | } catch (error) { 9 | return { resolved: false, data: error }; 10 | } 11 | } 12 | 13 | export async function getList(name) { 14 | try { 15 | const itemResonse = await axios.get(`${API_URL}/${name}.json`) 16 | return { resolved: true, data: itemResonse.data } 17 | } catch (error) { 18 | return { resolved: false, data: error } 19 | } 20 | } 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /src/components/Story.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Card, CardBody, CardTitle} from 'reactstrap'; 3 | import { handleClick } from '../utils/helper' 4 | 5 | const Story = ({ item }) => { 6 | return ( 7 | item.url? window.open(item.url, "_blank"): null}> 8 | 9 | {item.title} 10 |

{item.score} points by handleClick(event, 'user', item.by)}>{item.by} | handleClick(event, 'item', item.id)}>{item.kids? item.kids.length: 0} comments

11 |
12 |
13 | ) 14 | } 15 | 16 | export default Story 17 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | cache: 3 | yarn: true 4 | directories: 5 | - node_modules 6 | install: yarn install 7 | script: 8 | - yarn test 9 | - yarn build 10 | - cp CNAME ./build 11 | deploy: 12 | provider: pages 13 | skip_cleanup: true 14 | github_token: $GH_TOKEN 15 | local_dir: build 16 | on: 17 | branch: master 18 | before_install: yarn global add greenkeeper-lockfile@1 19 | before_script: greenkeeper-lockfile-update 20 | after_script: greenkeeper-lockfile-upload 21 | # Trigger a push build on master and greenkeeper branches + PRs build on every branches 22 | # Avoid double build on PRs (See https://github.com/travis-ci/travis-ci/issues/1147) 23 | branches: 24 | only: 25 | - master 26 | - /^greenkeeper.*$/ 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hacker News React [![Build Status](https://travis-ci.org/armujahid/hnreact.svg?branch=master)](https://travis-ci.org/armujahid/hnreact) [![Greenkeeper badge](https://badges.greenkeeper.io/armujahid/hnreact.svg)](https://greenkeeper.io/) 2 | 3 | This is a hacker news client written in react. Currently this has very limited functionality. 4 | 5 | ## Installation 6 | 7 | * install all project dependencies with `yarn install` or `npm install` 8 | * start the development server with `yarn start` or `npm start` 9 | * Production build can be created using `yarn build` or `npm run build` 10 | * Tests can be run using `yarn test` or `npm run test` 11 | 12 | ## Backend Server 13 | 14 | Currently https://hacker-news.firebaseio.com/v0/ is directly being used. Node.js GraphQL server may be added in future as a middleware 15 | 16 | ## License 17 | 18 | MIT License: https://armujahid.mit-license.org/@2018/ 19 | -------------------------------------------------------------------------------- /src/components/Item.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import Story from './Story' 3 | import Job from './Job' 4 | import { getItem } from '../utils/Api' 5 | import CustomLoader from './CustomLoader' 6 | 7 | const Item = props => { 8 | const [item, setItem] = useState(null) 9 | 10 | useEffect(() => { 11 | const fetchAndSetItem = async () => { 12 | const result = await getItem(props.id) 13 | if (result.resolved) { 14 | setItem(result.data) 15 | } 16 | } 17 | fetchAndSetItem() 18 | }, [props.id]) 19 | 20 | if (!item) { 21 | return 22 | } 23 | if (item.type === 'story') { 24 | return 25 | } 26 | if (item.type === 'job') { 27 | return 28 | } 29 | return ( 30 |
31 | Rendering of {item.type} is not currently supported :( 32 |
33 | ) 34 | } 35 | 36 | export default Item 37 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hnreact", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "axios": "^0.21.1", 7 | "bootstrap": "^4.3.1", 8 | "paginate-array": "^2.1.0", 9 | "react": "^16.10.2", 10 | "react-content-loader": "^5.0.0", 11 | "react-dom": "^16.10.2", 12 | "react-infinite-scroller": "^1.2.4", 13 | "react-router-dom": "^5.1.2", 14 | "react-scripts": "3.4.1", 15 | "reactstrap": "^8.1.1" 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 | "devDependencies": { 24 | "axios-mock-adapter": "^1.17.0", 25 | "eslint-plugin-jest": "^23.0.0", 26 | "eslint-plugin-react": "^7.16.0" 27 | }, 28 | "browserslist": [ 29 | ">0.2%", 30 | "not dead", 31 | "not ie <= 11", 32 | "not op_mini all" 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | The MIT License (MIT) 3 | Copyright © 2018 Abdul Rauf, https://armujahid.me 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/components/App.js: -------------------------------------------------------------------------------- 1 | import React, { Fragment } from 'react'; 2 | import { BrowserRouter as Router, Route, Switch } from 'react-router-dom' 3 | import NotFound from './404Page' 4 | import NavBar from './NavBar' 5 | import Stories from './Stories' 6 | 7 | const App = () => { 8 | return ( 9 | 10 | 11 |
12 | 13 |
14 | 15 | } /> 16 | } /> 17 | } /> 18 | } /> 19 | } /> 20 | } /> 21 | 22 | 23 |
24 |
25 |
26 |
27 | ) 28 | } 29 | 30 | export default App 31 | -------------------------------------------------------------------------------- /src/components/NavBar.js: -------------------------------------------------------------------------------- 1 | import React, { Fragment, useState } from 'react'; 2 | import { 3 | Collapse, 4 | Navbar, 5 | NavbarToggler, 6 | NavbarBrand, 7 | Nav, 8 | NavItem, 9 | NavLink } from 'reactstrap'; 10 | import { Link } from 'react-router-dom' 11 | 12 | const NavBar = () => { 13 | const [isOpen, setIsOpen] = useState(false) 14 | 15 | const toggle = () => { 16 | setIsOpen(!isOpen) 17 | } 18 | 19 | return ( 20 |
21 | 22 | HN React 23 | 24 | 25 | 26 | 46 | 47 | 48 | 49 |
50 | ) 51 | } 52 | 53 | export default NavBar 54 | -------------------------------------------------------------------------------- /src/components/Stories.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import Item from './Item' 3 | import { getList } from '../utils/Api' 4 | import InfiniteScroll from 'react-infinite-scroller'; 5 | import paginate from 'paginate-array' 6 | 7 | const Stories = props => { 8 | const [stories, setStories] = useState(null) 9 | const [renderlist, setrenderlist] = useState([]) 10 | const [hasMoreItems, sethasMoreItems] = useState(true) 11 | const [initialLoad, setinitialLoad] = useState(true) 12 | 13 | useEffect(() => { 14 | const fetchAndSetStories = async () => { 15 | const result = await getList(props.storytype) 16 | if (result.resolved) { 17 | setStories(result.data) 18 | } 19 | } 20 | fetchAndSetStories() 21 | }, [props.storytype]) 22 | 23 | const loadItems = (pageNumber) => { 24 | 25 | const pageItems = paginate(stories, pageNumber, 10) //10 items per page 26 | setrenderlist([...renderlist, ...pageItems.data]) 27 | setinitialLoad(false) 28 | sethasMoreItems((pageItems.currentPage !== pageItems.totalPages) && pageItems.data.length !== 0) 29 | } 30 | 31 | if (!stories) { 32 | return null 33 | } 34 | const loader =
Loading ...
; 35 | 36 | return ( 37 |
38 | 44 | {renderlist.map((id)=>)} 45 | 46 |
47 | ) 48 | } 49 | 50 | export default Stories; 51 | -------------------------------------------------------------------------------- /src/utils/Api.test.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import MockAdapter from 'axios-mock-adapter'; 3 | import { getItem, getList} from './Api'; 4 | import { API_URL } from './constants' 5 | 6 | describe('getItem', () => { 7 | it('success case', async () => { 8 | var mock = new MockAdapter(axios); 9 | const expectedResult = { resolved: true, data: 3 } 10 | mock.onGet(`${API_URL}/item/2.json`).reply(200, expectedResult.data); 11 | const result = await getItem(2) 12 | expect(result).toEqual(expectedResult) 13 | }) 14 | 15 | it('Error case', async () => { 16 | var mock = new MockAdapter(axios); 17 | const expectedResult = { resolved: false, data: new Error("Request failed with status code 500") } 18 | mock.onGet(`${API_URL}/item/2.json`).reply(500, expectedResult.data); 19 | const result = await getItem(2) 20 | expect(result).toEqual(expectedResult) 21 | }) 22 | }) 23 | 24 | describe('getList', () => { 25 | it('success case', async () => { 26 | var mock = new MockAdapter(axios); 27 | const expectedResult = { resolved: true, data: 3 } 28 | mock.onGet(`${API_URL}/2.json`).reply(200, expectedResult.data); 29 | const result = await getList(2) 30 | expect(result).toEqual(expectedResult) 31 | }) 32 | 33 | it('Error case', async () => { 34 | var mock = new MockAdapter(axios); 35 | const expectedResult = { resolved: false, data: new Error("Request failed with status code 500") } 36 | mock.onGet(`${API_URL}/2.json`).reply(500, expectedResult.data); 37 | const result = await getList(2) 38 | expect(result).toEqual(expectedResult) 39 | }) 40 | }) 41 | 42 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 13 | 22 | HN React 23 | 24 | 25 | 28 |
29 | 39 | 40 | 41 | --------------------------------------------------------------------------------