├── .gitignore ├── src ├── store │ ├── actionTypes.js │ ├── reducers.js │ ├── sagas.js │ ├── localStorage.js │ └── store.js ├── index.html ├── services │ └── authService.js ├── index.js ├── components │ ├── navigationBar.js │ └── article.js ├── routes.js ├── hoc │ └── authorization.js ├── app.js ├── pages │ ├── news.js │ ├── profile.js │ ├── home.js │ └── login.js └── style.less ├── .babelrc ├── .editorconfig ├── README.md ├── .eslintrc ├── webpack.config.js └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *~ 3 | *.log 4 | *.orig 5 | .idea 6 | .vscode 7 | .eslintcache 8 | dist/ -------------------------------------------------------------------------------- /src/store/actionTypes.js: -------------------------------------------------------------------------------- 1 | export const AUTHORIZATION_SUCCESS = 'AUTHORIZATION_SUCCESS'; 2 | export const AUTHORIZATION_FAIL = 'AUTHORIZATION_FAIL'; 3 | export const SIGN_OUT = 'SIGN_OUT'; 4 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["env", "react"], 3 | "plugins": [ 4 | "transform-class-properties", 5 | ["transform-runtime", { 6 | "polyfill": false, 7 | "regenerator": true 8 | }] 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | end_of_line = lf 7 | insert_final_newline = true 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | 11 | [**.*] 12 | indent_style = space 13 | indent_size = 2 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # About app 2 | 3 | The test app https://vk.com/@maxpfrontend-testovoe-zadanie-1 4 | 5 | # Install 6 | 7 | To install dependencies 8 | 9 | ```shell 10 | npm install 11 | ``` 12 | 13 | # Run 14 | 15 | To run app 16 | 17 | ```shell 18 | npm start 19 | ``` 20 | -------------------------------------------------------------------------------- /src/services/authService.js: -------------------------------------------------------------------------------- 1 | export const logIn = (username, password) => ( 2 | new Promise((resolve, reject) => { 3 | if (username === 'Admin' && password === '12345') { 4 | resolve(); 5 | } else { 6 | reject(new Error('Incorrect username or password.')); 7 | } 8 | }) 9 | ); 10 | 11 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from 'react-dom'; 3 | import { Provider } from 'react-redux'; 4 | import store from './store/store'; 5 | import App from './app'; 6 | import './style.less'; 7 | 8 | render( 9 | 10 | 11 | , 12 | document.getElementById('root') 13 | ); 14 | -------------------------------------------------------------------------------- /src/store/reducers.js: -------------------------------------------------------------------------------- 1 | import { AUTHORIZATION_SUCCESS, AUTHORIZATION_FAIL, SIGN_OUT } from './actionTypes'; 2 | 3 | const auth = (state = false, action) => { 4 | switch (action.type) { 5 | case AUTHORIZATION_SUCCESS: 6 | return { username: action.payload }; 7 | case SIGN_OUT: 8 | case AUTHORIZATION_FAIL: 9 | return { errorMessage: action.payload }; 10 | default: 11 | return state; 12 | } 13 | }; 14 | 15 | export default auth; 16 | -------------------------------------------------------------------------------- /src/components/navigationBar.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { NavLink } from 'react-router-dom'; 4 | 5 | const NavigationBar = (props) => ( 6 |
7 | {props.routes.map(route => 8 | {route.name} 9 | )} 10 |
11 | ); 12 | 13 | export default NavigationBar; 14 | 15 | NavigationBar.propTypes = { 16 | routes: PropTypes.array, 17 | }; 18 | -------------------------------------------------------------------------------- /src/store/sagas.js: -------------------------------------------------------------------------------- 1 | import { call, put, takeLatest } from 'redux-saga/effects'; 2 | import { logIn } from '../services/authService'; 3 | 4 | function* logInSaga({ payload }) { 5 | try { 6 | const { username, password } = payload; 7 | yield call(logIn, username, password); 8 | yield put({ type: 'AUTHORIZATION_SUCCESS', payload: username }); 9 | } catch (error) { 10 | yield put({ type: 'AUTHORIZATION_FAIL', payload: error.message, error: true }); 11 | } 12 | } 13 | 14 | export default function* () { 15 | yield takeLatest('LOG_IN', logInSaga); 16 | } 17 | -------------------------------------------------------------------------------- /src/store/localStorage.js: -------------------------------------------------------------------------------- 1 | export const loadState = () => { 2 | try { 3 | const serializedState = localStorage.getItem('state'); 4 | if (serializedState) { 5 | return JSON.parse(serializedState); 6 | } 7 | return undefined; 8 | } catch (err) { 9 | return undefined; 10 | } 11 | }; 12 | 13 | export const saveState = (state) => { 14 | try { 15 | const serializedState = JSON.stringify(state); 16 | localStorage.setItem('state', serializedState); 17 | } catch (err) { 18 | console.log('We received an error while saving the store'); 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "rules": { 4 | "indent": ["error", 2, {"SwitchCase": 1}], 5 | "quotes": ["error", "single"], 6 | "no-undef-init": 0, 7 | "import/prefer-default-export": 0, 8 | "arrow-parens": 0, 9 | "function-paren-newline": 0, 10 | "comma-dangle":0, 11 | "class-methods-use-this": 0, 12 | "no-underscore-dangle": 0, 13 | "no-console": 0 14 | }, 15 | "env": { 16 | "es6": true, 17 | "browser": true 18 | }, 19 | "extends": ["airbnb/base", "plugin:react/recommended"], 20 | "plugins": ["react", "import"] 21 | } 22 | -------------------------------------------------------------------------------- /src/routes.js: -------------------------------------------------------------------------------- 1 | import News from './pages/news'; 2 | import Profile from './pages/profile'; 3 | import Login from './pages/login'; 4 | import Home from './pages/home'; 5 | 6 | export const routes = [ 7 | { 8 | isNavBar: true, 9 | isExact: true, 10 | path: '/', 11 | name: 'Home', 12 | component: Home 13 | }, 14 | { 15 | isNavBar: true, 16 | path: '/news', 17 | name: 'News', 18 | component: News 19 | }, 20 | { 21 | isNavBar: true, 22 | path: '/profile', 23 | name: 'Profile', 24 | component: Profile, 25 | isPrivate: true 26 | }, 27 | { 28 | path: '/login', 29 | name: 'Login', 30 | component: Login 31 | } 32 | ]; 33 | -------------------------------------------------------------------------------- /src/store/store.js: -------------------------------------------------------------------------------- 1 | import { applyMiddleware, createStore, compose } from 'redux'; 2 | import createSagaMiddleware from 'redux-saga'; 3 | import reducers from './reducers'; 4 | import sagas from './sagas'; 5 | import { loadState, saveState } from './localStorage'; 6 | 7 | const sagaMiddleware = createSagaMiddleware(); 8 | 9 | const initialState = loadState(); 10 | 11 | const createStoreWithMiddleware = compose( 12 | applyMiddleware(sagaMiddleware) 13 | )(createStore); 14 | 15 | const store = createStoreWithMiddleware(reducers, initialState, 16 | window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__() 17 | ); 18 | 19 | store.subscribe(() => { 20 | saveState({ 21 | username: store.getState().username 22 | }); 23 | }); 24 | 25 | sagaMiddleware.run(sagas); 26 | 27 | export default store; 28 | -------------------------------------------------------------------------------- /src/hoc/authorization.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | import PropTypes from 'prop-types'; 4 | import { Redirect } from 'react-router-dom'; 5 | 6 | const Athorization = (WrappedComponent) => { 7 | class WithAuthorization extends React.Component { 8 | static propTypes = { 9 | isAuthorized: PropTypes.bool 10 | }; 11 | 12 | render() { 13 | const { isAuthorized } = this.props; 14 | 15 | if (!isAuthorized) { 16 | return ; 17 | } 18 | 19 | return ; 20 | } 21 | } 22 | 23 | const mapStateToProps = (state) => ( 24 | { 25 | isAuthorized: Boolean(state.username) 26 | } 27 | ); 28 | 29 | return connect(mapStateToProps)(WithAuthorization); 30 | }; 31 | 32 | export default Athorization; 33 | -------------------------------------------------------------------------------- /src/components/article.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | const Article = (props) => { 5 | const { 6 | urlToImage, title, url, author, description 7 | } = props; 8 | 9 | return ( 10 |
11 | { urlToImage && 12 |
13 | 14 |
15 | } 16 |
17 |
18 | {title} 19 |
20 | {author} 21 |
22 |
23 |

{description}

24 |
25 |
26 | ); 27 | }; 28 | 29 | Article.propTypes = { 30 | urlToImage: PropTypes.string, 31 | title: PropTypes.string.isRequired, 32 | url: PropTypes.string.isRequired, 33 | author: PropTypes.string, 34 | description: PropTypes.string 35 | }; 36 | 37 | export default Article; 38 | -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { BrowserRouter as Router, Route, Switch } from 'react-router-dom'; 3 | import { routes } from './routes'; 4 | import NavigationBar from './components/navigationBar'; 5 | import Authorization from './hoc/authorization'; 6 | 7 | const App = () => { 8 | const renderSwitch = () => ( 9 | 10 | {routes.map(route => { 11 | const component = route.isPrivate ? Authorization(route.component) : route.component; 12 | return ( 13 | 19 | ); 20 | })} 21 | 22 | ); 23 | 24 | return ( 25 | 26 | 27 | route.isNavBar)}/> 28 |
29 | {renderSwitch()} 30 |
31 |
32 |
33 | ); 34 | }; 35 | 36 | export default App; 37 | -------------------------------------------------------------------------------- /src/pages/news.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Article from '../components/article'; 3 | 4 | class News extends React.Component { 5 | constructor(props) { 6 | super(props); 7 | 8 | this.state = { 9 | articles: [] 10 | }; 11 | } 12 | 13 | componentDidMount() { 14 | const url = 'https://newsapi.org/v2/top-headlines?sources=hacker-news&apiKey=f22bcc2fb9a54185b76491b9c353d894'; 15 | fetch(url) 16 | .then(res => res.json()) 17 | .then(el => this.setState({ articles: el.articles })); 18 | } 19 | 20 | render() { 21 | return ( 22 |
23 |
24 |

News

25 |
26 | { this.state.articles.map((article, index) => 27 |
34 | )} 35 |
36 | ); 37 | } 38 | } 39 | 40 | export default News; 41 | -------------------------------------------------------------------------------- /src/pages/profile.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | import PropTypes from 'prop-types'; 4 | import { SIGN_OUT } from '../store/actionTypes'; 5 | 6 | 7 | class Profile extends React.Component { 8 | static propTypes = { 9 | signOut: PropTypes.func.isRequired, 10 | username: PropTypes.string.isRequired 11 | }; 12 | 13 | render() { 14 | return ( 15 |
16 |
17 |

Profile

18 |
19 |
20 |
21 | 22 | {this.props.username} 23 |
24 | 25 |
26 |
27 | ); 28 | } 29 | 30 | signOut = () => { 31 | this.props.signOut(); 32 | }; 33 | } 34 | 35 | const mapStateToProps = (state) => ( 36 | { 37 | username: state.username 38 | } 39 | ); 40 | 41 | const mapDispatchToProps = (dispatch) => ( 42 | { 43 | signOut: () => dispatch({ type: SIGN_OUT }) 44 | } 45 | ); 46 | 47 | export default connect(mapStateToProps, mapDispatchToProps)(Profile); 48 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const ExtractTextPlugin = require('extract-text-webpack-plugin'); 3 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 4 | 5 | module.exports = { 6 | target: 'web', 7 | entry: { main: './src/index.js' }, 8 | output: { 9 | path: path.resolve(__dirname, 'dist'), 10 | filename: 'main.js', 11 | publicPath: '/', 12 | }, 13 | module: { 14 | rules: [ 15 | { 16 | test: [/\.js$/, /\.jsx$/], 17 | exclude: /node_modules/, 18 | use: { 19 | loader: 'babel-loader', 20 | }, 21 | }, 22 | { 23 | test: /\.less$/, 24 | use: ExtractTextPlugin.extract({ 25 | fallback: 'style-loader', 26 | use: ['css-loader', 'less-loader'], 27 | }), 28 | }, 29 | { 30 | test: [/\.js$/, /\.jsx$/], 31 | exclude: /node_modules/, 32 | loader: 'eslint-loader', 33 | }, 34 | ], 35 | }, 36 | devServer: { 37 | historyApiFallback: true, 38 | }, 39 | plugins: [ 40 | new ExtractTextPlugin({ filename: 'style.css' }), 41 | new HtmlWebpackPlugin({ 42 | inject: false, 43 | hash: true, 44 | template: './src/index.html', 45 | filename: 'index.html', 46 | }), 47 | ], 48 | }; 49 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "learning-without-water-test-1", 3 | "description": "basics of React, Redux, React-Router", 4 | "author": "artbocha", 5 | "scripts": { 6 | "start": "webpack-dev-server --mode development --open", 7 | "dev": "webpack --mode development", 8 | "build": "webpack --mode production", 9 | "lint": "eslint src --cache --ext .js --color --format codeframe" 10 | }, 11 | "devDependencies": { 12 | "babel-core": "^6.26.0", 13 | "babel-eslint": "^8.2.3", 14 | "babel-loader": "^7.1.4", 15 | "babel-plugin-transform-class-properties": "^6.24.1", 16 | "babel-plugin-transform-runtime": "^6.23.0", 17 | "babel-preset-env": "^1.6.1", 18 | "babel-preset-react": "^6.24.1", 19 | "css-loader": "^0.28.11", 20 | "eslint": "^4.19.1", 21 | "eslint-config-airbnb": "^16.1.0", 22 | "eslint-loader": "^2.0.0", 23 | "eslint-plugin-import": "^2.11.0", 24 | "eslint-plugin-react": "^7.7.0", 25 | "extract-text-webpack-plugin": "^4.0.0-beta.0", 26 | "html-webpack-plugin": "^3.2.0", 27 | "less": "^3.0.1", 28 | "less-loader": "^4.1.0", 29 | "style-loader": "^0.20.3", 30 | "webpack": "^4.5.0", 31 | "webpack-cli": "^2.0.14", 32 | "webpack-dev-server": "^3.1.3" 33 | }, 34 | "dependencies": { 35 | "prop-types": "^15.6.1", 36 | "react": "^16.3.1", 37 | "react-dom": "^16.3.1", 38 | "react-redux": "^5.0.7", 39 | "react-router-dom": "^4.2.2", 40 | "redux": "^4.0.0", 41 | "redux-saga": "^0.16.0" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/pages/home.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Home = () => ( 4 | 5 |

Useful react and redux links

6 | 43 |
44 | ); 45 | 46 | export default Home; 47 | -------------------------------------------------------------------------------- /src/pages/login.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import PropTypes from 'prop-types'; 3 | import React from 'react'; 4 | import { Redirect } from 'react-router-dom'; 5 | 6 | class Login extends React.Component { 7 | static propTypes = { 8 | isAuthorized: PropTypes.bool, 9 | logIn: PropTypes.func.isRequired, 10 | error: PropTypes.string 11 | }; 12 | 13 | constructor(props) { 14 | super(props); 15 | 16 | this.state = { 17 | username: '', 18 | password: '' 19 | }; 20 | } 21 | 22 | render() { 23 | const { isAuthorized } = this.props; 24 | 25 | if (isAuthorized) { 26 | return ; 27 | } 28 | 29 | const { username, password } = this.state; 30 | const { error } = this.props; 31 | 32 | return ( 33 |
34 |
35 | 36 | 37 | 38 | 39 | 40 | 43 |
44 |
45 | ); 46 | } 47 | 48 | onChangeUsername = (event) => { 49 | const { target: { value } } = event; 50 | 51 | this.setState({ username: value }); 52 | } 53 | 54 | onChangePassword = (event) => { 55 | const { target: { value } } = event; 56 | 57 | this.setState({ password: value }); 58 | } 59 | 60 | handleSubmit = (event) => { 61 | event.preventDefault(); 62 | const { username, password } = this.state; 63 | 64 | this.props.logIn(username, password); 65 | } 66 | } 67 | 68 | const mapStateToProps = (state) => ( 69 | { 70 | isAuthorized: Boolean(state.username), 71 | error: state.errorMessage 72 | } 73 | ); 74 | 75 | const mapDispatchToProps = (dispatch) => ( 76 | { 77 | logIn: (username, password) => dispatch({ type: 'LOG_IN', payload: { username, password } }), 78 | } 79 | ); 80 | 81 | export default connect(mapStateToProps, mapDispatchToProps)(Login); 82 | -------------------------------------------------------------------------------- /src/style.less: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: "Segoe UI",Helvetica,Arial,sans-serif; 3 | background-color: #dcefff; 4 | margin: 0; 5 | } 6 | 7 | h2 { 8 | margin-top: 0; 9 | margin-bottom: 0; 10 | } 11 | 12 | .nav-bar { 13 | background-color: #24292e; 14 | color: rgba(255,255,255,0.75); 15 | overflow: hidden; 16 | height: 50px; 17 | display: flex; 18 | justify-content: center; 19 | align-items: center; 20 | 21 | a { 22 | color: #aef9d7; 23 | text-align: center; 24 | padding-left: 10px; 25 | text-decoration: none; 26 | font-size: 16px; 27 | } 28 | 29 | a.active { 30 | color: #fff; 31 | } 32 | 33 | a:hover { 34 | color: rgb(243, 223, 136); 35 | } 36 | } 37 | 38 | #ui-content { 39 | padding: 1%; 40 | 41 | .news.header > h1 42 | { 43 | padding-left: 15px; 44 | display: inline; 45 | } 46 | } 47 | 48 | input[type=text], input[type=password] { 49 | width: 100%; 50 | padding: 12px 20px; 51 | margin: 8px 0; 52 | display: inline-block; 53 | border: 1px solid #ccc; 54 | box-sizing: border-box; 55 | } 56 | 57 | input:disabled { 58 | background-color: #fafbfc; 59 | } 60 | 61 | #login-form { 62 | width: 400px; 63 | height: 300px; 64 | padding: 20px; 65 | background-color: #bfccd0; 66 | position: absolute; 67 | top: 50%; 68 | left: 50%; 69 | margin-right: -50%; 70 | transform: translate(-50%, -50%); 71 | 72 | .error-message { 73 | color: red; 74 | } 75 | } 76 | 77 | #login-form button { 78 | width: 100%; 79 | } 80 | 81 | button { 82 | background-color: #74bb77; 83 | color: white; 84 | padding: 14px 20px; 85 | margin: 8px 0; 86 | border: none; 87 | cursor: pointer; 88 | } 89 | 90 | article { 91 | display: flex; 92 | border-radius: 2px; 93 | background-color: #fff; 94 | box-shadow: 0 1px 3px 0 rgba(0,0,0,0.16), 0 0 0 1px rgba(0,0,0,0.04); 95 | margin-top: 8px; 96 | margin-bottom: 8px; 97 | padding: 15px; 98 | 99 | .img-wrap { 100 | margin-right: 15px; 101 | width: 200px; 102 | height: 200px; 103 | 104 | img { 105 | height: 100%; 106 | width: 100%; 107 | } 108 | } 109 | 110 | .title > a { 111 | font-weight: 500; 112 | font-size: 18px; 113 | color: black; 114 | } 115 | 116 | .title > .author { 117 | font-size: 12px; 118 | line-height: 20px; 119 | color: #757575; 120 | } 121 | } 122 | 123 | a { 124 | text-decoration: none; 125 | } 126 | 127 | a:hover { 128 | text-decoration: underline; 129 | color: blue; 130 | } 131 | 132 | 133 | .header.profile { 134 | border-bottom: 1px #858c95 solid; 135 | padding-bottom: 8px; 136 | } 137 | 138 | .profile-info { 139 | width: 33%; 140 | 141 | label { 142 | margin-bottom: 6px; 143 | font-weight: 600; 144 | } 145 | 146 | label + span { 147 | margin-left: 8px; 148 | font-size: 14px; 149 | } 150 | } 151 | 152 | .group { 153 | margin-top: 6px; 154 | } 155 | 156 | li > p { 157 | margin-top: 16px; 158 | } 159 | --------------------------------------------------------------------------------