├── .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 |
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 |
--------------------------------------------------------------------------------