├── .gitignore
├── LICENSE.md
├── README.md
├── package.json
├── project-logo.png
├── public
├── favicon.ico
└── index.html
└── src
├── agent.js
├── components
├── App.js
├── Article
│ ├── ArticleActions.js
│ ├── ArticleMeta.js
│ ├── Comment.js
│ ├── CommentContainer.js
│ ├── CommentInput.js
│ ├── CommentList.js
│ ├── DeleteButton.js
│ └── index.js
├── ArticleList.js
├── ArticlePreview.js
├── Editor.js
├── Header.js
├── Home
│ ├── Banner.js
│ ├── MainView.js
│ ├── Tags.js
│ └── index.js
├── ListErrors.js
├── ListPagination.js
├── Login.js
├── Profile.js
├── ProfileFavorites.js
├── Register.js
└── Settings.js
├── constants
└── actionTypes.js
├── index.js
├── middleware.js
├── reducer.js
├── reducers
├── article.js
├── articleList.js
├── auth.js
├── common.js
├── editor.js
├── home.js
├── profile.js
└── settings.js
└── store.js
/.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 | .idea
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 GoThinkster
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 | # 
2 |
3 | [](http://realworld.io)
4 |
5 | > ### React + Redux codebase containing real world examples (CRUD, auth, advanced patterns, etc) that adheres to the [RealWorld](https://github.com/gothinkster/realworld-example-apps) spec and API.
6 |
7 |
8 |
9 | ### [Demo](https://react-redux.realworld.io) [RealWorld](https://github.com/gothinkster/realworld)
10 |
11 | Originally created for this [GH issue](https://github.com/reactjs/redux/issues/1353). The codebase is now feature complete; please submit bug fixes via pull requests & feedback via issues.
12 |
13 | We also have notes in [**our wiki**](https://github.com/gothinkster/react-redux-realworld-example-app/wiki) about how the various patterns used in this codebase and how they work (thanks [@thejmazz](https://github.com/thejmazz)!)
14 |
15 |
16 | ## Getting started
17 |
18 | You can view a live demo over at https://react-redux.realworld.io/
19 |
20 | To get the frontend running locally:
21 |
22 | - Clone this repo
23 | - `npm install` to install all req'd dependencies
24 | - `npm start` to start the local server (this project uses create-react-app)
25 |
26 | Local web server will use port 4100 instead of standard React's port 3000 to prevent conflicts with some backends like Node or Rails. You can configure port in scripts section of `package.json`: we use [cross-env](https://github.com/kentcdodds/cross-env) to set environment variable PORT for React scripts, this is Windows-compatible way of setting environment variables.
27 |
28 | Alternatively, you can add `.env` file in the root folder of project to set environment variables (use PORT to change webserver's port). This file will be ignored by git, so it is suitable for API keys and other sensitive stuff. Refer to [dotenv](https://github.com/motdotla/dotenv) and [React](https://github.com/facebookincubator/create-react-app/blob/master/packages/react-scripts/template/README.md#adding-development-environment-variables-in-env) documentation for more details. Also, please remove setting variable via script section of `package.json` - `dotenv` never override variables if they are already set.
29 |
30 | ### Making requests to the backend API
31 |
32 | For convenience, we have a live API server running at https://conduit.productionready.io/api for the application to make requests against. You can view [the API spec here](https://github.com/GoThinkster/productionready/blob/master/api) which contains all routes & responses for the server.
33 |
34 | The source code for the backend server (available for Node, Rails and Django) can be found in the [main RealWorld repo](https://github.com/gothinkster/realworld).
35 |
36 | If you want to change the API URL to a local server, simply edit `src/agent.js` and change `API_ROOT` to the local server's URL (i.e. `http://localhost:3000/api`)
37 |
38 |
39 | ## Functionality overview
40 |
41 | The example application is a social blogging site (i.e. a Medium.com clone) called "Conduit". It uses a custom API for all requests, including authentication. You can view a live demo over at https://redux.productionready.io/
42 |
43 | **General functionality:**
44 |
45 | - Authenticate users via JWT (login/signup pages + logout button on settings page)
46 | - CRU* users (sign up & settings page - no deleting required)
47 | - CRUD Articles
48 | - CR*D Comments on articles (no updating required)
49 | - GET and display paginated lists of articles
50 | - Favorite articles
51 | - Follow other users
52 |
53 | **The general page breakdown looks like this:**
54 |
55 | - Home page (URL: /#/ )
56 | - List of tags
57 | - List of articles pulled from either Feed, Global, or by Tag
58 | - Pagination for list of articles
59 | - Sign in/Sign up pages (URL: /#/login, /#/register )
60 | - Use JWT (store the token in localStorage)
61 | - Settings page (URL: /#/settings )
62 | - Editor page to create/edit articles (URL: /#/editor, /#/editor/article-slug-here )
63 | - Article page (URL: /#/article/article-slug-here )
64 | - Delete article button (only shown to article's author)
65 | - Render markdown from server client side
66 | - Comments section at bottom of page
67 | - Delete comment button (only shown to comment's author)
68 | - Profile page (URL: /#/@username, /#/@username/favorites )
69 | - Show basic user info
70 | - List of articles populated from author's created articles or author's favorited articles
71 |
72 |
73 |
74 | [](https://thinkster.io)
75 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-redux-realworld-example-app",
3 | "version": "0.1.0",
4 | "private": true,
5 | "devDependencies": {
6 | "cross-env": "^5.1.4",
7 | "react-scripts": "1.1.1"
8 | },
9 | "dependencies": {
10 | "history": "^4.6.3",
11 | "marked": "^0.3.6",
12 | "prop-types": "^15.5.10",
13 | "react": "^16.3.0",
14 | "react-dom": "^16.3.0",
15 | "react-redux": "^5.0.7",
16 | "react-router": "^4.1.2",
17 | "react-router-dom": "^4.1.2",
18 | "react-router-redux": "^5.0.0-alpha.6",
19 | "redux": "^3.6.0",
20 | "redux-devtools-extension": "^2.13.2",
21 | "redux-logger": "^3.0.1",
22 | "superagent": "^3.8.2",
23 | "superagent-promise": "^1.1.0"
24 | },
25 | "scripts": {
26 | "start": "cross-env PORT=4100 react-scripts start",
27 | "build": "react-scripts build",
28 | "test": "cross-env PORT=4100 react-scripts test --env=jsdom",
29 | "eject": "react-scripts eject"
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/project-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/spider-yamet/react-redux-realworld-example-app/d81f84444c21031c5fce1d5505eb612f3ac84548/project-logo.png
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/spider-yamet/react-redux-realworld-example-app/d81f84444c21031c5fce1d5505eb612f3ac84548/public/favicon.ico
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
19 | Conduit
20 |
21 |
22 |
23 |
33 |
34 |
35 |
--------------------------------------------------------------------------------
/src/agent.js:
--------------------------------------------------------------------------------
1 | import superagentPromise from 'superagent-promise';
2 | import _superagent from 'superagent';
3 |
4 | const superagent = superagentPromise(_superagent, global.Promise);
5 |
6 | const API_ROOT = 'https://conduit.productionready.io/api';
7 |
8 | const encode = encodeURIComponent;
9 | const responseBody = res => res.body;
10 |
11 | let token = null;
12 | const tokenPlugin = req => {
13 | if (token) {
14 | req.set('authorization', `Token ${token}`);
15 | }
16 | }
17 |
18 | const requests = {
19 | del: url =>
20 | superagent.del(`${API_ROOT}${url}`).use(tokenPlugin).then(responseBody),
21 | get: url =>
22 | superagent.get(`${API_ROOT}${url}`).use(tokenPlugin).then(responseBody),
23 | put: (url, body) =>
24 | superagent.put(`${API_ROOT}${url}`, body).use(tokenPlugin).then(responseBody),
25 | post: (url, body) =>
26 | superagent.post(`${API_ROOT}${url}`, body).use(tokenPlugin).then(responseBody)
27 | };
28 |
29 | const Auth = {
30 | current: () =>
31 | requests.get('/user'),
32 | login: (email, password) =>
33 | requests.post('/users/login', { user: { email, password } }),
34 | register: (username, email, password) =>
35 | requests.post('/users', { user: { username, email, password } }),
36 | save: user =>
37 | requests.put('/user', { user })
38 | };
39 |
40 | const Tags = {
41 | getAll: () => requests.get('/tags')
42 | };
43 |
44 | const limit = (count, p) => `limit=${count}&offset=${p ? p * count : 0}`;
45 | const omitSlug = article => Object.assign({}, article, { slug: undefined })
46 | const Articles = {
47 | all: page =>
48 | requests.get(`/articles?${limit(10, page)}`),
49 | byAuthor: (author, page) =>
50 | requests.get(`/articles?author=${encode(author)}&${limit(5, page)}`),
51 | byTag: (tag, page) =>
52 | requests.get(`/articles?tag=${encode(tag)}&${limit(10, page)}`),
53 | del: slug =>
54 | requests.del(`/articles/${slug}`),
55 | favorite: slug =>
56 | requests.post(`/articles/${slug}/favorite`),
57 | favoritedBy: (author, page) =>
58 | requests.get(`/articles?favorited=${encode(author)}&${limit(5, page)}`),
59 | feed: () =>
60 | requests.get('/articles/feed?limit=10&offset=0'),
61 | get: slug =>
62 | requests.get(`/articles/${slug}`),
63 | unfavorite: slug =>
64 | requests.del(`/articles/${slug}/favorite`),
65 | update: article =>
66 | requests.put(`/articles/${article.slug}`, { article: omitSlug(article) }),
67 | create: article =>
68 | requests.post('/articles', { article })
69 | };
70 |
71 | const Comments = {
72 | create: (slug, comment) =>
73 | requests.post(`/articles/${slug}/comments`, { comment }),
74 | delete: (slug, commentId) =>
75 | requests.del(`/articles/${slug}/comments/${commentId}`),
76 | forArticle: slug =>
77 | requests.get(`/articles/${slug}/comments`)
78 | };
79 |
80 | const Profile = {
81 | follow: username =>
82 | requests.post(`/profiles/${username}/follow`),
83 | get: username =>
84 | requests.get(`/profiles/${username}`),
85 | unfollow: username =>
86 | requests.del(`/profiles/${username}/follow`)
87 | };
88 |
89 | export default {
90 | Articles,
91 | Auth,
92 | Comments,
93 | Profile,
94 | Tags,
95 | setToken: _token => { token = _token; }
96 | };
97 |
--------------------------------------------------------------------------------
/src/components/App.js:
--------------------------------------------------------------------------------
1 | import agent from '../agent';
2 | import Header from './Header';
3 | import React from 'react';
4 | import { connect } from 'react-redux';
5 | import { APP_LOAD, REDIRECT } from '../constants/actionTypes';
6 | import { Route, Switch } from 'react-router-dom';
7 | import Article from '../components/Article';
8 | import Editor from '../components/Editor';
9 | import Home from '../components/Home';
10 | import Login from '../components/Login';
11 | import Profile from '../components/Profile';
12 | import ProfileFavorites from '../components/ProfileFavorites';
13 | import Register from '../components/Register';
14 | import Settings from '../components/Settings';
15 | import { store } from '../store';
16 | import { push } from 'react-router-redux';
17 |
18 | const mapStateToProps = state => {
19 | return {
20 | appLoaded: state.common.appLoaded,
21 | appName: state.common.appName,
22 | currentUser: state.common.currentUser,
23 | redirectTo: state.common.redirectTo
24 | }};
25 |
26 | const mapDispatchToProps = dispatch => ({
27 | onLoad: (payload, token) =>
28 | dispatch({ type: APP_LOAD, payload, token, skipTracking: true }),
29 | onRedirect: () =>
30 | dispatch({ type: REDIRECT })
31 | });
32 |
33 | class App extends React.Component {
34 | componentWillReceiveProps(nextProps) {
35 | if (nextProps.redirectTo) {
36 | // this.context.router.replace(nextProps.redirectTo);
37 | store.dispatch(push(nextProps.redirectTo));
38 | this.props.onRedirect();
39 | }
40 | }
41 |
42 | componentWillMount() {
43 | const token = window.localStorage.getItem('jwt');
44 | if (token) {
45 | agent.setToken(token);
46 | }
47 |
48 | this.props.onLoad(token ? agent.Auth.current() : null, token);
49 | }
50 |
51 | render() {
52 | if (this.props.appLoaded) {
53 | return (
54 |
55 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 | );
71 | }
72 | return (
73 |
74 |
77 |
78 | );
79 | }
80 | }
81 |
82 | // App.contextTypes = {
83 | // router: PropTypes.object.isRequired
84 | // };
85 |
86 | export default connect(mapStateToProps, mapDispatchToProps)(App);
87 |
--------------------------------------------------------------------------------
/src/components/Article/ArticleActions.js:
--------------------------------------------------------------------------------
1 | import { Link } from 'react-router-dom';
2 | import React from 'react';
3 | import agent from '../../agent';
4 | import { connect } from 'react-redux';
5 | import { DELETE_ARTICLE } from '../../constants/actionTypes';
6 |
7 | const mapDispatchToProps = dispatch => ({
8 | onClickDelete: payload =>
9 | dispatch({ type: DELETE_ARTICLE, payload })
10 | });
11 |
12 | const ArticleActions = props => {
13 | const article = props.article;
14 | const del = () => {
15 | props.onClickDelete(agent.Articles.del(article.slug))
16 | };
17 | if (props.canModify) {
18 | return (
19 |
20 |
21 |
24 | Edit Article
25 |
26 |
27 |
28 | Delete Article
29 |
30 |
31 |
32 | );
33 | }
34 |
35 | return (
36 |
37 |
38 | );
39 | };
40 |
41 | export default connect(() => ({}), mapDispatchToProps)(ArticleActions);
42 |
--------------------------------------------------------------------------------
/src/components/Article/ArticleMeta.js:
--------------------------------------------------------------------------------
1 | import ArticleActions from './ArticleActions';
2 | import { Link } from 'react-router-dom';
3 | import React from 'react';
4 |
5 | const ArticleMeta = props => {
6 | const article = props.article;
7 | return (
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | {article.author.username}
16 |
17 |
18 | {new Date(article.createdAt).toDateString()}
19 |
20 |
21 |
22 |
23 |
24 | );
25 | };
26 |
27 | export default ArticleMeta;
28 |
--------------------------------------------------------------------------------
/src/components/Article/Comment.js:
--------------------------------------------------------------------------------
1 | import DeleteButton from './DeleteButton';
2 | import { Link } from 'react-router-dom';
3 | import React from 'react';
4 |
5 | const Comment = props => {
6 | const comment = props.comment;
7 | const show = props.currentUser &&
8 | props.currentUser.username === comment.author.username;
9 | return (
10 |
11 |
14 |
15 |
18 |
19 |
20 |
21 |
24 | {comment.author.username}
25 |
26 |
27 | {new Date(comment.createdAt).toDateString()}
28 |
29 |
30 |
31 |
32 | );
33 | };
34 |
35 | export default Comment;
36 |
--------------------------------------------------------------------------------
/src/components/Article/CommentContainer.js:
--------------------------------------------------------------------------------
1 | import CommentInput from './CommentInput';
2 | import CommentList from './CommentList';
3 | import { Link } from 'react-router-dom';
4 | import React from 'react';
5 |
6 | const CommentContainer = props => {
7 | if (props.currentUser) {
8 | return (
9 |
10 |
11 |
12 |
13 |
14 |
15 |
19 |
20 | );
21 | } else {
22 | return (
23 |
24 |
25 | Sign in
26 | or
27 | sign up
28 | to add comments on this article.
29 |
30 |
31 |
35 |
36 | );
37 | }
38 | };
39 |
40 | export default CommentContainer;
41 |
--------------------------------------------------------------------------------
/src/components/Article/CommentInput.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import agent from '../../agent';
3 | import { connect } from 'react-redux';
4 | import { ADD_COMMENT } from '../../constants/actionTypes';
5 |
6 | const mapDispatchToProps = dispatch => ({
7 | onSubmit: payload =>
8 | dispatch({ type: ADD_COMMENT, payload })
9 | });
10 |
11 | class CommentInput extends React.Component {
12 | constructor() {
13 | super();
14 | this.state = {
15 | body: ''
16 | };
17 |
18 | this.setBody = ev => {
19 | this.setState({ body: ev.target.value });
20 | };
21 |
22 | this.createComment = ev => {
23 | ev.preventDefault();
24 | const payload = agent.Comments.create(this.props.slug,
25 | { body: this.state.body });
26 | this.setState({ body: '' });
27 | this.props.onSubmit(payload);
28 | };
29 | }
30 |
31 | render() {
32 | return (
33 |
54 | );
55 | }
56 | }
57 |
58 | export default connect(() => ({}), mapDispatchToProps)(CommentInput);
59 |
--------------------------------------------------------------------------------
/src/components/Article/CommentList.js:
--------------------------------------------------------------------------------
1 | import Comment from './Comment';
2 | import React from 'react';
3 |
4 | const CommentList = props => {
5 | return (
6 |
7 | {
8 | props.comments.map(comment => {
9 | return (
10 |
15 | );
16 | })
17 | }
18 |
19 | );
20 | };
21 |
22 | export default CommentList;
23 |
--------------------------------------------------------------------------------
/src/components/Article/DeleteButton.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import agent from '../../agent';
3 | import { connect } from 'react-redux';
4 | import { DELETE_COMMENT } from '../../constants/actionTypes';
5 |
6 | const mapDispatchToProps = dispatch => ({
7 | onClick: (payload, commentId) =>
8 | dispatch({ type: DELETE_COMMENT, payload, commentId })
9 | });
10 |
11 | const DeleteButton = props => {
12 | const del = () => {
13 | const payload = agent.Comments.delete(props.slug, props.commentId);
14 | props.onClick(payload, props.commentId);
15 | };
16 |
17 | if (props.show) {
18 | return (
19 |
20 |
21 |
22 | );
23 | }
24 | return null;
25 | };
26 |
27 | export default connect(() => ({}), mapDispatchToProps)(DeleteButton);
28 |
--------------------------------------------------------------------------------
/src/components/Article/index.js:
--------------------------------------------------------------------------------
1 | import ArticleMeta from './ArticleMeta';
2 | import CommentContainer from './CommentContainer';
3 | import React from 'react';
4 | import agent from '../../agent';
5 | import { connect } from 'react-redux';
6 | import marked from 'marked';
7 | import { ARTICLE_PAGE_LOADED, ARTICLE_PAGE_UNLOADED } from '../../constants/actionTypes';
8 |
9 | const mapStateToProps = state => ({
10 | ...state.article,
11 | currentUser: state.common.currentUser
12 | });
13 |
14 | const mapDispatchToProps = dispatch => ({
15 | onLoad: payload =>
16 | dispatch({ type: ARTICLE_PAGE_LOADED, payload }),
17 | onUnload: () =>
18 | dispatch({ type: ARTICLE_PAGE_UNLOADED })
19 | });
20 |
21 | class Article extends React.Component {
22 | componentWillMount() {
23 | this.props.onLoad(Promise.all([
24 | agent.Articles.get(this.props.match.params.id),
25 | agent.Comments.forArticle(this.props.match.params.id)
26 | ]));
27 | }
28 |
29 | componentWillUnmount() {
30 | this.props.onUnload();
31 | }
32 |
33 | render() {
34 | if (!this.props.article) {
35 | return null;
36 | }
37 |
38 | const markup = { __html: marked(this.props.article.body, { sanitize: true }) };
39 | const canModify = this.props.currentUser &&
40 | this.props.currentUser.username === this.props.article.author.username;
41 | return (
42 |
43 |
44 |
45 |
46 |
47 |
{this.props.article.title}
48 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 | {
64 | this.props.article.tagList.map(tag => {
65 | return (
66 |
69 | {tag}
70 |
71 | );
72 | })
73 | }
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
90 |
91 |
92 |
93 | );
94 | }
95 | }
96 |
97 | export default connect(mapStateToProps, mapDispatchToProps)(Article);
98 |
--------------------------------------------------------------------------------
/src/components/ArticleList.js:
--------------------------------------------------------------------------------
1 | import ArticlePreview from './ArticlePreview';
2 | import ListPagination from './ListPagination';
3 | import React from 'react';
4 |
5 | const ArticleList = props => {
6 | if (!props.articles) {
7 | return (
8 | Loading...
9 | );
10 | }
11 |
12 | if (props.articles.length === 0) {
13 | return (
14 |
15 | No articles are here... yet.
16 |
17 | );
18 | }
19 |
20 | return (
21 |
22 | {
23 | props.articles.map(article => {
24 | return (
25 |
26 | );
27 | })
28 | }
29 |
30 |
34 |
35 | );
36 | };
37 |
38 | export default ArticleList;
39 |
--------------------------------------------------------------------------------
/src/components/ArticlePreview.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Link } from 'react-router-dom';
3 | import agent from '../agent';
4 | import { connect } from 'react-redux';
5 | import { ARTICLE_FAVORITED, ARTICLE_UNFAVORITED } from '../constants/actionTypes';
6 |
7 | const FAVORITED_CLASS = 'btn btn-sm btn-primary';
8 | const NOT_FAVORITED_CLASS = 'btn btn-sm btn-outline-primary';
9 |
10 | const mapDispatchToProps = dispatch => ({
11 | favorite: slug => dispatch({
12 | type: ARTICLE_FAVORITED,
13 | payload: agent.Articles.favorite(slug)
14 | }),
15 | unfavorite: slug => dispatch({
16 | type: ARTICLE_UNFAVORITED,
17 | payload: agent.Articles.unfavorite(slug)
18 | })
19 | });
20 |
21 | const ArticlePreview = props => {
22 | const article = props.article;
23 | const favoriteButtonClass = article.favorited ?
24 | FAVORITED_CLASS :
25 | NOT_FAVORITED_CLASS;
26 |
27 | const handleClick = ev => {
28 | ev.preventDefault();
29 | if (article.favorited) {
30 | props.unfavorite(article.slug);
31 | } else {
32 | props.favorite(article.slug);
33 | }
34 | };
35 |
36 | return (
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 | {article.author.username}
46 |
47 |
48 | {new Date(article.createdAt).toDateString()}
49 |
50 |
51 |
52 |
53 |
54 | {article.favoritesCount}
55 |
56 |
57 |
58 |
59 |
60 |
{article.title}
61 |
{article.description}
62 |
Read more...
63 |
64 | {
65 | article.tagList.map(tag => {
66 | return (
67 |
68 | {tag}
69 |
70 | )
71 | })
72 | }
73 |
74 |
75 |
76 | );
77 | }
78 |
79 | export default connect(() => ({}), mapDispatchToProps)(ArticlePreview);
80 |
--------------------------------------------------------------------------------
/src/components/Editor.js:
--------------------------------------------------------------------------------
1 | import ListErrors from './ListErrors';
2 | import React from 'react';
3 | import agent from '../agent';
4 | import { connect } from 'react-redux';
5 | import {
6 | ADD_TAG,
7 | EDITOR_PAGE_LOADED,
8 | REMOVE_TAG,
9 | ARTICLE_SUBMITTED,
10 | EDITOR_PAGE_UNLOADED,
11 | UPDATE_FIELD_EDITOR
12 | } from '../constants/actionTypes';
13 |
14 | const mapStateToProps = state => ({
15 | ...state.editor
16 | });
17 |
18 | const mapDispatchToProps = dispatch => ({
19 | onAddTag: () =>
20 | dispatch({ type: ADD_TAG }),
21 | onLoad: payload =>
22 | dispatch({ type: EDITOR_PAGE_LOADED, payload }),
23 | onRemoveTag: tag =>
24 | dispatch({ type: REMOVE_TAG, tag }),
25 | onSubmit: payload =>
26 | dispatch({ type: ARTICLE_SUBMITTED, payload }),
27 | onUnload: payload =>
28 | dispatch({ type: EDITOR_PAGE_UNLOADED }),
29 | onUpdateField: (key, value) =>
30 | dispatch({ type: UPDATE_FIELD_EDITOR, key, value })
31 | });
32 |
33 | class Editor extends React.Component {
34 | constructor() {
35 | super();
36 |
37 | const updateFieldEvent =
38 | key => ev => this.props.onUpdateField(key, ev.target.value);
39 | this.changeTitle = updateFieldEvent('title');
40 | this.changeDescription = updateFieldEvent('description');
41 | this.changeBody = updateFieldEvent('body');
42 | this.changeTagInput = updateFieldEvent('tagInput');
43 |
44 | this.watchForEnter = ev => {
45 | if (ev.keyCode === 13) {
46 | ev.preventDefault();
47 | this.props.onAddTag();
48 | }
49 | };
50 |
51 | this.removeTagHandler = tag => () => {
52 | this.props.onRemoveTag(tag);
53 | };
54 |
55 | this.submitForm = ev => {
56 | ev.preventDefault();
57 | const article = {
58 | title: this.props.title,
59 | description: this.props.description,
60 | body: this.props.body,
61 | tagList: this.props.tagList
62 | };
63 |
64 | const slug = { slug: this.props.articleSlug };
65 | const promise = this.props.articleSlug ?
66 | agent.Articles.update(Object.assign(article, slug)) :
67 | agent.Articles.create(article);
68 |
69 | this.props.onSubmit(promise);
70 | };
71 | }
72 |
73 | componentWillReceiveProps(nextProps) {
74 | if (this.props.match.params.slug !== nextProps.match.params.slug) {
75 | if (nextProps.match.params.slug) {
76 | this.props.onUnload();
77 | return this.props.onLoad(agent.Articles.get(this.props.match.params.slug));
78 | }
79 | this.props.onLoad(null);
80 | }
81 | }
82 |
83 | componentWillMount() {
84 | if (this.props.match.params.slug) {
85 | return this.props.onLoad(agent.Articles.get(this.props.match.params.slug));
86 | }
87 | this.props.onLoad(null);
88 | }
89 |
90 | componentWillUnmount() {
91 | this.props.onUnload();
92 | }
93 |
94 | render() {
95 | return (
96 |
174 | );
175 | }
176 | }
177 |
178 | export default connect(mapStateToProps, mapDispatchToProps)(Editor);
179 |
--------------------------------------------------------------------------------
/src/components/Header.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Link } from 'react-router-dom';
3 |
4 | const LoggedOutView = props => {
5 | if (!props.currentUser) {
6 | return (
7 |
8 |
9 |
10 |
11 | Home
12 |
13 |
14 |
15 |
16 |
17 | Sign in
18 |
19 |
20 |
21 |
22 |
23 | Sign up
24 |
25 |
26 |
27 |
28 | );
29 | }
30 | return null;
31 | };
32 |
33 | const LoggedInView = props => {
34 | if (props.currentUser) {
35 | return (
36 |
37 |
38 |
39 |
40 | Home
41 |
42 |
43 |
44 |
45 |
46 | New Post
47 |
48 |
49 |
50 |
51 |
52 | Settings
53 |
54 |
55 |
56 |
57 |
60 |
61 | {props.currentUser.username}
62 |
63 |
64 |
65 |
66 | );
67 | }
68 |
69 | return null;
70 | };
71 |
72 | class Header extends React.Component {
73 | render() {
74 | return (
75 |
76 |
77 |
78 |
79 | {this.props.appName.toLowerCase()}
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 | );
88 | }
89 | }
90 |
91 | export default Header;
92 |
--------------------------------------------------------------------------------
/src/components/Home/Banner.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | const Banner = ({ appName, token }) => {
4 | if (token) {
5 | return null;
6 | }
7 | return (
8 |
9 |
10 |
11 | {appName.toLowerCase()}
12 |
13 |
A place to share your knowledge.
14 |
15 |
16 | );
17 | };
18 |
19 | export default Banner;
20 |
--------------------------------------------------------------------------------
/src/components/Home/MainView.js:
--------------------------------------------------------------------------------
1 | import ArticleList from '../ArticleList';
2 | import React from 'react';
3 | import agent from '../../agent';
4 | import { connect } from 'react-redux';
5 | import { CHANGE_TAB } from '../../constants/actionTypes';
6 |
7 | const YourFeedTab = props => {
8 | if (props.token) {
9 | const clickHandler = ev => {
10 | ev.preventDefault();
11 | props.onTabClick('feed', agent.Articles.feed, agent.Articles.feed());
12 | }
13 |
14 | return (
15 |
16 |
19 | Your Feed
20 |
21 |
22 | );
23 | }
24 | return null;
25 | };
26 |
27 | const GlobalFeedTab = props => {
28 | const clickHandler = ev => {
29 | ev.preventDefault();
30 | props.onTabClick('all', agent.Articles.all, agent.Articles.all());
31 | };
32 | return (
33 |
34 |
38 | Global Feed
39 |
40 |
41 | );
42 | };
43 |
44 | const TagFilterTab = props => {
45 | if (!props.tag) {
46 | return null;
47 | }
48 |
49 | return (
50 |
51 |
52 | {props.tag}
53 |
54 |
55 | );
56 | };
57 |
58 | const mapStateToProps = state => ({
59 | ...state.articleList,
60 | tags: state.home.tags,
61 | token: state.common.token
62 | });
63 |
64 | const mapDispatchToProps = dispatch => ({
65 | onTabClick: (tab, pager, payload) => dispatch({ type: CHANGE_TAB, tab, pager, payload })
66 | });
67 |
68 | const MainView = props => {
69 | return (
70 |
71 |
72 |
73 |
74 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
92 |
93 | );
94 | };
95 |
96 | export default connect(mapStateToProps, mapDispatchToProps)(MainView);
97 |
--------------------------------------------------------------------------------
/src/components/Home/Tags.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import agent from '../../agent';
3 |
4 | const Tags = props => {
5 | const tags = props.tags;
6 | if (tags) {
7 | return (
8 |
9 | {
10 | tags.map(tag => {
11 | const handleClick = ev => {
12 | ev.preventDefault();
13 | props.onClickTag(tag, page => agent.Articles.byTag(tag, page), agent.Articles.byTag(tag));
14 | };
15 |
16 | return (
17 |
22 | {tag}
23 |
24 | );
25 | })
26 | }
27 |
28 | );
29 | } else {
30 | return (
31 | Loading Tags...
32 | );
33 | }
34 | };
35 |
36 | export default Tags;
37 |
--------------------------------------------------------------------------------
/src/components/Home/index.js:
--------------------------------------------------------------------------------
1 | import Banner from './Banner';
2 | import MainView from './MainView';
3 | import React from 'react';
4 | import Tags from './Tags';
5 | import agent from '../../agent';
6 | import { connect } from 'react-redux';
7 | import {
8 | HOME_PAGE_LOADED,
9 | HOME_PAGE_UNLOADED,
10 | APPLY_TAG_FILTER
11 | } from '../../constants/actionTypes';
12 |
13 | const Promise = global.Promise;
14 |
15 | const mapStateToProps = state => ({
16 | ...state.home,
17 | appName: state.common.appName,
18 | token: state.common.token
19 | });
20 |
21 | const mapDispatchToProps = dispatch => ({
22 | onClickTag: (tag, pager, payload) =>
23 | dispatch({ type: APPLY_TAG_FILTER, tag, pager, payload }),
24 | onLoad: (tab, pager, payload) =>
25 | dispatch({ type: HOME_PAGE_LOADED, tab, pager, payload }),
26 | onUnload: () =>
27 | dispatch({ type: HOME_PAGE_UNLOADED })
28 | });
29 |
30 | class Home extends React.Component {
31 | componentWillMount() {
32 | const tab = this.props.token ? 'feed' : 'all';
33 | const articlesPromise = this.props.token ?
34 | agent.Articles.feed :
35 | agent.Articles.all;
36 |
37 | this.props.onLoad(tab, articlesPromise, Promise.all([agent.Tags.getAll(), articlesPromise()]));
38 | }
39 |
40 | componentWillUnmount() {
41 | this.props.onUnload();
42 | }
43 |
44 | render() {
45 | return (
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
Popular Tags
58 |
59 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 | );
70 | }
71 | }
72 |
73 | export default connect(mapStateToProps, mapDispatchToProps)(Home);
74 |
--------------------------------------------------------------------------------
/src/components/ListErrors.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | class ListErrors extends React.Component {
4 | render() {
5 | const errors = this.props.errors;
6 | if (errors) {
7 | return (
8 |
9 | {
10 | Object.keys(errors).map(key => {
11 | return (
12 |
13 | {key} {errors[key]}
14 |
15 | );
16 | })
17 | }
18 |
19 | );
20 | } else {
21 | return null;
22 | }
23 | }
24 | }
25 |
26 | export default ListErrors;
27 |
--------------------------------------------------------------------------------
/src/components/ListPagination.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import agent from '../agent';
3 | import { connect } from 'react-redux';
4 | import { SET_PAGE } from '../constants/actionTypes';
5 |
6 | const mapDispatchToProps = dispatch => ({
7 | onSetPage: (page, payload) =>
8 | dispatch({ type: SET_PAGE, page, payload })
9 | });
10 |
11 | const ListPagination = props => {
12 | if (props.articlesCount <= 10) {
13 | return null;
14 | }
15 |
16 | const range = [];
17 | for (let i = 0; i < Math.ceil(props.articlesCount / 10); ++i) {
18 | range.push(i);
19 | }
20 |
21 | const setPage = page => {
22 | if(props.pager) {
23 | props.onSetPage(page, props.pager(page));
24 | }else {
25 | props.onSetPage(page, agent.Articles.all(page))
26 | }
27 | };
28 |
29 | return (
30 |
31 |
32 |
33 | {
34 | range.map(v => {
35 | const isCurrent = v === props.currentPage;
36 | const onClick = ev => {
37 | ev.preventDefault();
38 | setPage(v);
39 | };
40 | return (
41 |
45 |
46 | {v + 1}
47 |
48 |
49 | );
50 | })
51 | }
52 |
53 |
54 |
55 | );
56 | };
57 |
58 | export default connect(() => ({}), mapDispatchToProps)(ListPagination);
59 |
--------------------------------------------------------------------------------
/src/components/Login.js:
--------------------------------------------------------------------------------
1 | import { Link } from 'react-router-dom';
2 | import ListErrors from './ListErrors';
3 | import React from 'react';
4 | import agent from '../agent';
5 | import { connect } from 'react-redux';
6 | import {
7 | UPDATE_FIELD_AUTH,
8 | LOGIN,
9 | LOGIN_PAGE_UNLOADED
10 | } from '../constants/actionTypes';
11 |
12 | const mapStateToProps = state => ({ ...state.auth });
13 |
14 | const mapDispatchToProps = dispatch => ({
15 | onChangeEmail: value =>
16 | dispatch({ type: UPDATE_FIELD_AUTH, key: 'email', value }),
17 | onChangePassword: value =>
18 | dispatch({ type: UPDATE_FIELD_AUTH, key: 'password', value }),
19 | onSubmit: (email, password) =>
20 | dispatch({ type: LOGIN, payload: agent.Auth.login(email, password) }),
21 | onUnload: () =>
22 | dispatch({ type: LOGIN_PAGE_UNLOADED })
23 | });
24 |
25 | class Login extends React.Component {
26 | constructor() {
27 | super();
28 | this.changeEmail = ev => this.props.onChangeEmail(ev.target.value);
29 | this.changePassword = ev => this.props.onChangePassword(ev.target.value);
30 | this.submitForm = (email, password) => ev => {
31 | ev.preventDefault();
32 | this.props.onSubmit(email, password);
33 | };
34 | }
35 |
36 | componentWillUnmount() {
37 | this.props.onUnload();
38 | }
39 |
40 | render() {
41 | const email = this.props.email;
42 | const password = this.props.password;
43 | return (
44 |
45 |
46 |
47 |
48 |
49 |
Sign In
50 |
51 |
52 | Need an account?
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
68 |
69 |
70 |
71 |
77 |
78 |
79 |
83 | Sign in
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 | );
94 | }
95 | }
96 |
97 | export default connect(mapStateToProps, mapDispatchToProps)(Login);
98 |
--------------------------------------------------------------------------------
/src/components/Profile.js:
--------------------------------------------------------------------------------
1 | import ArticleList from './ArticleList';
2 | import React from 'react';
3 | import { Link } from 'react-router-dom';
4 | import agent from '../agent';
5 | import { connect } from 'react-redux';
6 | import {
7 | FOLLOW_USER,
8 | UNFOLLOW_USER,
9 | PROFILE_PAGE_LOADED,
10 | PROFILE_PAGE_UNLOADED
11 | } from '../constants/actionTypes';
12 |
13 | const EditProfileSettings = props => {
14 | if (props.isUser) {
15 | return (
16 |
19 | Edit Profile Settings
20 |
21 | );
22 | }
23 | return null;
24 | };
25 |
26 | const FollowUserButton = props => {
27 | if (props.isUser) {
28 | return null;
29 | }
30 |
31 | let classes = 'btn btn-sm action-btn';
32 | if (props.user.following) {
33 | classes += ' btn-secondary';
34 | } else {
35 | classes += ' btn-outline-secondary';
36 | }
37 |
38 | const handleClick = ev => {
39 | ev.preventDefault();
40 | if (props.user.following) {
41 | props.unfollow(props.user.username)
42 | } else {
43 | props.follow(props.user.username)
44 | }
45 | };
46 |
47 | return (
48 |
51 |
52 |
53 | {props.user.following ? 'Unfollow' : 'Follow'} {props.user.username}
54 |
55 | );
56 | };
57 |
58 | const mapStateToProps = state => ({
59 | ...state.articleList,
60 | currentUser: state.common.currentUser,
61 | profile: state.profile
62 | });
63 |
64 | const mapDispatchToProps = dispatch => ({
65 | onFollow: username => dispatch({
66 | type: FOLLOW_USER,
67 | payload: agent.Profile.follow(username)
68 | }),
69 | onLoad: payload => dispatch({ type: PROFILE_PAGE_LOADED, payload }),
70 | onUnfollow: username => dispatch({
71 | type: UNFOLLOW_USER,
72 | payload: agent.Profile.unfollow(username)
73 | }),
74 | onUnload: () => dispatch({ type: PROFILE_PAGE_UNLOADED })
75 | });
76 |
77 | class Profile extends React.Component {
78 | componentWillMount() {
79 | this.props.onLoad(Promise.all([
80 | agent.Profile.get(this.props.match.params.username),
81 | agent.Articles.byAuthor(this.props.match.params.username)
82 | ]));
83 | }
84 |
85 | componentWillUnmount() {
86 | this.props.onUnload();
87 | }
88 |
89 | renderTabs() {
90 | return (
91 |
92 |
93 |
96 | My Articles
97 |
98 |
99 |
100 |
101 |
104 | Favorited Articles
105 |
106 |
107 |
108 | );
109 | }
110 |
111 | render() {
112 | const profile = this.props.profile;
113 | if (!profile) {
114 | return null;
115 | }
116 |
117 | const isUser = this.props.currentUser &&
118 | this.props.profile.username === this.props.currentUser.username;
119 |
120 | return (
121 |
122 |
123 |
124 |
125 |
126 |
127 |
128 |
129 |
{profile.username}
130 |
{profile.bio}
131 |
132 |
133 |
139 |
140 |
141 |
142 |
143 |
144 |
145 |
146 |
147 |
148 |
149 |
150 |
151 | {this.renderTabs()}
152 |
153 |
154 |
159 |
160 |
161 |
162 |
163 |
164 |
165 | );
166 | }
167 | }
168 |
169 | export default connect(mapStateToProps, mapDispatchToProps)(Profile);
170 | export { Profile, mapStateToProps };
171 |
--------------------------------------------------------------------------------
/src/components/ProfileFavorites.js:
--------------------------------------------------------------------------------
1 | import { Profile, mapStateToProps } from './Profile';
2 | import React from 'react';
3 | import { Link } from 'react-router-dom';
4 | import agent from '../agent';
5 | import { connect } from 'react-redux';
6 | import {
7 | PROFILE_PAGE_LOADED,
8 | PROFILE_PAGE_UNLOADED
9 | } from '../constants/actionTypes';
10 |
11 | const mapDispatchToProps = dispatch => ({
12 | onLoad: (pager, payload) =>
13 | dispatch({ type: PROFILE_PAGE_LOADED, pager, payload }),
14 | onUnload: () =>
15 | dispatch({ type: PROFILE_PAGE_UNLOADED })
16 | });
17 |
18 | class ProfileFavorites extends Profile {
19 | componentWillMount() {
20 | this.props.onLoad(page => agent.Articles.favoritedBy(this.props.match.params.username, page), Promise.all([
21 | agent.Profile.get(this.props.match.params.username),
22 | agent.Articles.favoritedBy(this.props.match.params.username)
23 | ]));
24 | }
25 |
26 | componentWillUnmount() {
27 | this.props.onUnload();
28 | }
29 |
30 | renderTabs() {
31 | return (
32 |
33 |
34 |
37 | My Articles
38 |
39 |
40 |
41 |
42 |
45 | Favorited Articles
46 |
47 |
48 |
49 | );
50 | }
51 | }
52 |
53 | export default connect(mapStateToProps, mapDispatchToProps)(ProfileFavorites);
54 |
--------------------------------------------------------------------------------
/src/components/Register.js:
--------------------------------------------------------------------------------
1 | import { Link } from 'react-router-dom';
2 | import ListErrors from './ListErrors';
3 | import React from 'react';
4 | import agent from '../agent';
5 | import { connect } from 'react-redux';
6 | import {
7 | UPDATE_FIELD_AUTH,
8 | REGISTER,
9 | REGISTER_PAGE_UNLOADED
10 | } from '../constants/actionTypes';
11 |
12 | const mapStateToProps = state => ({ ...state.auth });
13 |
14 | const mapDispatchToProps = dispatch => ({
15 | onChangeEmail: value =>
16 | dispatch({ type: UPDATE_FIELD_AUTH, key: 'email', value }),
17 | onChangePassword: value =>
18 | dispatch({ type: UPDATE_FIELD_AUTH, key: 'password', value }),
19 | onChangeUsername: value =>
20 | dispatch({ type: UPDATE_FIELD_AUTH, key: 'username', value }),
21 | onSubmit: (username, email, password) => {
22 | const payload = agent.Auth.register(username, email, password);
23 | dispatch({ type: REGISTER, payload })
24 | },
25 | onUnload: () =>
26 | dispatch({ type: REGISTER_PAGE_UNLOADED })
27 | });
28 |
29 | class Register extends React.Component {
30 | constructor() {
31 | super();
32 | this.changeEmail = ev => this.props.onChangeEmail(ev.target.value);
33 | this.changePassword = ev => this.props.onChangePassword(ev.target.value);
34 | this.changeUsername = ev => this.props.onChangeUsername(ev.target.value);
35 | this.submitForm = (username, email, password) => ev => {
36 | ev.preventDefault();
37 | this.props.onSubmit(username, email, password);
38 | }
39 | }
40 |
41 | componentWillUnmount() {
42 | this.props.onUnload();
43 | }
44 |
45 | render() {
46 | const email = this.props.email;
47 | const password = this.props.password;
48 | const username = this.props.username;
49 |
50 | return (
51 |
109 | );
110 | }
111 | }
112 |
113 | export default connect(mapStateToProps, mapDispatchToProps)(Register);
114 |
--------------------------------------------------------------------------------
/src/components/Settings.js:
--------------------------------------------------------------------------------
1 | import ListErrors from './ListErrors';
2 | import React from 'react';
3 | import agent from '../agent';
4 | import { connect } from 'react-redux';
5 | import {
6 | SETTINGS_SAVED,
7 | SETTINGS_PAGE_UNLOADED,
8 | LOGOUT
9 | } from '../constants/actionTypes';
10 |
11 | class SettingsForm extends React.Component {
12 | constructor() {
13 | super();
14 |
15 | this.state = {
16 | image: '',
17 | username: '',
18 | bio: '',
19 | email: '',
20 | password: ''
21 | };
22 |
23 | this.updateState = field => ev => {
24 | const state = this.state;
25 | const newState = Object.assign({}, state, { [field]: ev.target.value });
26 | this.setState(newState);
27 | };
28 |
29 | this.submitForm = ev => {
30 | ev.preventDefault();
31 |
32 | const user = Object.assign({}, this.state);
33 | if (!user.password) {
34 | delete user.password;
35 | }
36 |
37 | this.props.onSubmitForm(user);
38 | };
39 | }
40 |
41 | componentWillMount() {
42 | if (this.props.currentUser) {
43 | Object.assign(this.state, {
44 | image: this.props.currentUser.image || '',
45 | username: this.props.currentUser.username,
46 | bio: this.props.currentUser.bio,
47 | email: this.props.currentUser.email
48 | });
49 | }
50 | }
51 |
52 | componentWillReceiveProps(nextProps) {
53 | if (nextProps.currentUser) {
54 | this.setState(Object.assign({}, this.state, {
55 | image: nextProps.currentUser.image || '',
56 | username: nextProps.currentUser.username,
57 | bio: nextProps.currentUser.bio,
58 | email: nextProps.currentUser.email
59 | }));
60 | }
61 | }
62 |
63 | render() {
64 | return (
65 |
66 |
67 |
68 |
69 |
75 |
76 |
77 |
78 |
84 |
85 |
86 |
87 |
93 |
94 |
95 |
96 |
97 |
103 |
104 |
105 |
106 |
112 |
113 |
114 |
118 | Update Settings
119 |
120 |
121 |
122 |
123 | );
124 | }
125 | }
126 |
127 | const mapStateToProps = state => ({
128 | ...state.settings,
129 | currentUser: state.common.currentUser
130 | });
131 |
132 | const mapDispatchToProps = dispatch => ({
133 | onClickLogout: () => dispatch({ type: LOGOUT }),
134 | onSubmitForm: user =>
135 | dispatch({ type: SETTINGS_SAVED, payload: agent.Auth.save(user) }),
136 | onUnload: () => dispatch({ type: SETTINGS_PAGE_UNLOADED })
137 | });
138 |
139 | class Settings extends React.Component {
140 | render() {
141 | return (
142 |
143 |
144 |
145 |
146 |
147 |
Your Settings
148 |
149 |
150 |
151 |
154 |
155 |
156 |
157 |
160 | Or click here to logout.
161 |
162 |
163 |
164 |
165 |
166 |
167 | );
168 | }
169 | }
170 |
171 | export default connect(mapStateToProps, mapDispatchToProps)(Settings);
172 |
--------------------------------------------------------------------------------
/src/constants/actionTypes.js:
--------------------------------------------------------------------------------
1 | export const APP_LOAD = 'APP_LOAD';
2 | export const REDIRECT = 'REDIRECT';
3 | export const ARTICLE_SUBMITTED = 'ARTICLE_SUBMITTED';
4 | export const SETTINGS_SAVED = 'SETTINGS_SAVED';
5 | export const DELETE_ARTICLE = 'DELETE_ARTICLE';
6 | export const SETTINGS_PAGE_UNLOADED = 'SETTINGS_PAGE_UNLOADED';
7 | export const HOME_PAGE_LOADED = 'HOME_PAGE_LOADED';
8 | export const HOME_PAGE_UNLOADED = 'HOME_PAGE_UNLOADED';
9 | export const ARTICLE_PAGE_LOADED = 'ARTICLE_PAGE_LOADED';
10 | export const ARTICLE_PAGE_UNLOADED = 'ARTICLE_PAGE_UNLOADED';
11 | export const ADD_COMMENT = 'ADD_COMMENT';
12 | export const DELETE_COMMENT = 'DELETE_COMMENT';
13 | export const ARTICLE_FAVORITED = 'ARTICLE_FAVORITED';
14 | export const ARTICLE_UNFAVORITED = 'ARTICLE_UNFAVORITED';
15 | export const SET_PAGE = 'SET_PAGE';
16 | export const APPLY_TAG_FILTER = 'APPLY_TAG_FILTER';
17 | export const CHANGE_TAB = 'CHANGE_TAB';
18 | export const PROFILE_PAGE_LOADED = 'PROFILE_PAGE_LOADED';
19 | export const PROFILE_PAGE_UNLOADED = 'PROFILE_PAGE_UNLOADED';
20 | export const LOGIN = 'LOGIN';
21 | export const LOGOUT = 'LOGOUT';
22 | export const REGISTER = 'REGISTER';
23 | export const LOGIN_PAGE_UNLOADED = 'LOGIN_PAGE_UNLOADED';
24 | export const REGISTER_PAGE_UNLOADED = 'REGISTER_PAGE_UNLOADED';
25 | export const ASYNC_START = 'ASYNC_START';
26 | export const ASYNC_END = 'ASYNC_END';
27 | export const EDITOR_PAGE_LOADED = 'EDITOR_PAGE_LOADED';
28 | export const EDITOR_PAGE_UNLOADED = 'EDITOR_PAGE_UNLOADED';
29 | export const ADD_TAG = 'ADD_TAG';
30 | export const REMOVE_TAG = 'REMOVE_TAG';
31 | export const UPDATE_FIELD_AUTH = 'UPDATE_FIELD_AUTH';
32 | export const UPDATE_FIELD_EDITOR = 'UPDATE_FIELD_EDITOR';
33 | export const FOLLOW_USER = 'FOLLOW_USER';
34 | export const UNFOLLOW_USER = 'UNFOLLOW_USER';
35 | export const PROFILE_FAVORITES_PAGE_UNLOADED = 'PROFILE_FAVORITES_PAGE_UNLOADED';
36 | export const PROFILE_FAVORITES_PAGE_LOADED = 'PROFILE_FAVORITES_PAGE_LOADED';
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import ReactDOM from 'react-dom';
2 | import { Provider } from 'react-redux';
3 | import React from 'react';
4 | import { store, history} from './store';
5 |
6 | import { Route, Switch } from 'react-router-dom';
7 | import { ConnectedRouter } from 'react-router-redux';
8 |
9 | import App from './components/App';
10 |
11 | ReactDOM.render((
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | ), document.getElementById('root'));
21 |
--------------------------------------------------------------------------------
/src/middleware.js:
--------------------------------------------------------------------------------
1 | import agent from './agent';
2 | import {
3 | ASYNC_START,
4 | ASYNC_END,
5 | LOGIN,
6 | LOGOUT,
7 | REGISTER
8 | } from './constants/actionTypes';
9 |
10 | const promiseMiddleware = store => next => action => {
11 | if (isPromise(action.payload)) {
12 | store.dispatch({ type: ASYNC_START, subtype: action.type });
13 |
14 | const currentView = store.getState().viewChangeCounter;
15 | const skipTracking = action.skipTracking;
16 |
17 | action.payload.then(
18 | res => {
19 | const currentState = store.getState()
20 | if (!skipTracking && currentState.viewChangeCounter !== currentView) {
21 | return
22 | }
23 | console.log('RESULT', res);
24 | action.payload = res;
25 | store.dispatch({ type: ASYNC_END, promise: action.payload });
26 | store.dispatch(action);
27 | },
28 | error => {
29 | const currentState = store.getState()
30 | if (!skipTracking && currentState.viewChangeCounter !== currentView) {
31 | return
32 | }
33 | console.log('ERROR', error);
34 | action.error = true;
35 | action.payload = error.response.body;
36 | if (!action.skipTracking) {
37 | store.dispatch({ type: ASYNC_END, promise: action.payload });
38 | }
39 | store.dispatch(action);
40 | }
41 | );
42 |
43 | return;
44 | }
45 |
46 | next(action);
47 | };
48 |
49 | const localStorageMiddleware = store => next => action => {
50 | if (action.type === REGISTER || action.type === LOGIN) {
51 | if (!action.error) {
52 | window.localStorage.setItem('jwt', action.payload.user.token);
53 | agent.setToken(action.payload.user.token);
54 | }
55 | } else if (action.type === LOGOUT) {
56 | window.localStorage.setItem('jwt', '');
57 | agent.setToken(null);
58 | }
59 |
60 | next(action);
61 | };
62 |
63 | function isPromise(v) {
64 | return v && typeof v.then === 'function';
65 | }
66 |
67 |
68 | export { promiseMiddleware, localStorageMiddleware }
69 |
--------------------------------------------------------------------------------
/src/reducer.js:
--------------------------------------------------------------------------------
1 | import article from './reducers/article';
2 | import articleList from './reducers/articleList';
3 | import auth from './reducers/auth';
4 | import { combineReducers } from 'redux';
5 | import common from './reducers/common';
6 | import editor from './reducers/editor';
7 | import home from './reducers/home';
8 | import profile from './reducers/profile';
9 | import settings from './reducers/settings';
10 | import { routerReducer } from 'react-router-redux';
11 |
12 | export default combineReducers({
13 | article,
14 | articleList,
15 | auth,
16 | common,
17 | editor,
18 | home,
19 | profile,
20 | settings,
21 | router: routerReducer
22 | });
23 |
--------------------------------------------------------------------------------
/src/reducers/article.js:
--------------------------------------------------------------------------------
1 | import {
2 | ARTICLE_PAGE_LOADED,
3 | ARTICLE_PAGE_UNLOADED,
4 | ADD_COMMENT,
5 | DELETE_COMMENT
6 | } from '../constants/actionTypes';
7 |
8 | export default (state = {}, action) => {
9 | switch (action.type) {
10 | case ARTICLE_PAGE_LOADED:
11 | return {
12 | ...state,
13 | article: action.payload[0].article,
14 | comments: action.payload[1].comments
15 | };
16 | case ARTICLE_PAGE_UNLOADED:
17 | return {};
18 | case ADD_COMMENT:
19 | return {
20 | ...state,
21 | commentErrors: action.error ? action.payload.errors : null,
22 | comments: action.error ?
23 | null :
24 | (state.comments || []).concat([action.payload.comment])
25 | };
26 | case DELETE_COMMENT:
27 | const commentId = action.commentId
28 | return {
29 | ...state,
30 | comments: state.comments.filter(comment => comment.id !== commentId)
31 | };
32 | default:
33 | return state;
34 | }
35 | };
36 |
--------------------------------------------------------------------------------
/src/reducers/articleList.js:
--------------------------------------------------------------------------------
1 | import {
2 | ARTICLE_FAVORITED,
3 | ARTICLE_UNFAVORITED,
4 | SET_PAGE,
5 | APPLY_TAG_FILTER,
6 | HOME_PAGE_LOADED,
7 | HOME_PAGE_UNLOADED,
8 | CHANGE_TAB,
9 | PROFILE_PAGE_LOADED,
10 | PROFILE_PAGE_UNLOADED,
11 | PROFILE_FAVORITES_PAGE_LOADED,
12 | PROFILE_FAVORITES_PAGE_UNLOADED
13 | } from '../constants/actionTypes';
14 |
15 | export default (state = {}, action) => {
16 | switch (action.type) {
17 | case ARTICLE_FAVORITED:
18 | case ARTICLE_UNFAVORITED:
19 | return {
20 | ...state,
21 | articles: state.articles.map(article => {
22 | if (article.slug === action.payload.article.slug) {
23 | return {
24 | ...article,
25 | favorited: action.payload.article.favorited,
26 | favoritesCount: action.payload.article.favoritesCount
27 | };
28 | }
29 | return article;
30 | })
31 | };
32 | case SET_PAGE:
33 | return {
34 | ...state,
35 | articles: action.payload.articles,
36 | articlesCount: action.payload.articlesCount,
37 | currentPage: action.page
38 | };
39 | case APPLY_TAG_FILTER:
40 | return {
41 | ...state,
42 | pager: action.pager,
43 | articles: action.payload.articles,
44 | articlesCount: action.payload.articlesCount,
45 | tab: null,
46 | tag: action.tag,
47 | currentPage: 0
48 | };
49 | case HOME_PAGE_LOADED:
50 | return {
51 | ...state,
52 | pager: action.pager,
53 | tags: action.payload[0].tags,
54 | articles: action.payload[1].articles,
55 | articlesCount: action.payload[1].articlesCount,
56 | currentPage: 0,
57 | tab: action.tab
58 | };
59 | case HOME_PAGE_UNLOADED:
60 | return {};
61 | case CHANGE_TAB:
62 | return {
63 | ...state,
64 | pager: action.pager,
65 | articles: action.payload.articles,
66 | articlesCount: action.payload.articlesCount,
67 | tab: action.tab,
68 | currentPage: 0,
69 | tag: null
70 | };
71 | case PROFILE_PAGE_LOADED:
72 | case PROFILE_FAVORITES_PAGE_LOADED:
73 | return {
74 | ...state,
75 | pager: action.pager,
76 | articles: action.payload[1].articles,
77 | articlesCount: action.payload[1].articlesCount,
78 | currentPage: 0
79 | };
80 | case PROFILE_PAGE_UNLOADED:
81 | case PROFILE_FAVORITES_PAGE_UNLOADED:
82 | return {};
83 | default:
84 | return state;
85 | }
86 | };
87 |
--------------------------------------------------------------------------------
/src/reducers/auth.js:
--------------------------------------------------------------------------------
1 | import {
2 | LOGIN,
3 | REGISTER,
4 | LOGIN_PAGE_UNLOADED,
5 | REGISTER_PAGE_UNLOADED,
6 | ASYNC_START,
7 | UPDATE_FIELD_AUTH
8 | } from '../constants/actionTypes';
9 |
10 | export default (state = {}, action) => {
11 | switch (action.type) {
12 | case LOGIN:
13 | case REGISTER:
14 | return {
15 | ...state,
16 | inProgress: false,
17 | errors: action.error ? action.payload.errors : null
18 | };
19 | case LOGIN_PAGE_UNLOADED:
20 | case REGISTER_PAGE_UNLOADED:
21 | return {};
22 | case ASYNC_START:
23 | if (action.subtype === LOGIN || action.subtype === REGISTER) {
24 | return { ...state, inProgress: true };
25 | }
26 | break;
27 | case UPDATE_FIELD_AUTH:
28 | return { ...state, [action.key]: action.value };
29 | default:
30 | return state;
31 | }
32 |
33 | return state;
34 | };
35 |
--------------------------------------------------------------------------------
/src/reducers/common.js:
--------------------------------------------------------------------------------
1 | import {
2 | APP_LOAD,
3 | REDIRECT,
4 | LOGOUT,
5 | ARTICLE_SUBMITTED,
6 | SETTINGS_SAVED,
7 | LOGIN,
8 | REGISTER,
9 | DELETE_ARTICLE,
10 | ARTICLE_PAGE_UNLOADED,
11 | EDITOR_PAGE_UNLOADED,
12 | HOME_PAGE_UNLOADED,
13 | PROFILE_PAGE_UNLOADED,
14 | PROFILE_FAVORITES_PAGE_UNLOADED,
15 | SETTINGS_PAGE_UNLOADED,
16 | LOGIN_PAGE_UNLOADED,
17 | REGISTER_PAGE_UNLOADED
18 | } from '../constants/actionTypes';
19 |
20 | const defaultState = {
21 | appName: 'Conduit',
22 | token: null,
23 | viewChangeCounter: 0
24 | };
25 |
26 | export default (state = defaultState, action) => {
27 | switch (action.type) {
28 | case APP_LOAD:
29 | return {
30 | ...state,
31 | token: action.token || null,
32 | appLoaded: true,
33 | currentUser: action.payload ? action.payload.user : null
34 | };
35 | case REDIRECT:
36 | return { ...state, redirectTo: null };
37 | case LOGOUT:
38 | return { ...state, redirectTo: '/', token: null, currentUser: null };
39 | case ARTICLE_SUBMITTED:
40 | const redirectUrl = `/article/${action.payload.article.slug}`;
41 | return { ...state, redirectTo: redirectUrl };
42 | case SETTINGS_SAVED:
43 | return {
44 | ...state,
45 | redirectTo: action.error ? null : '/',
46 | currentUser: action.error ? null : action.payload.user
47 | };
48 | case LOGIN:
49 | case REGISTER:
50 | return {
51 | ...state,
52 | redirectTo: action.error ? null : '/',
53 | token: action.error ? null : action.payload.user.token,
54 | currentUser: action.error ? null : action.payload.user
55 | };
56 | case DELETE_ARTICLE:
57 | return { ...state, redirectTo: '/' };
58 | case ARTICLE_PAGE_UNLOADED:
59 | case EDITOR_PAGE_UNLOADED:
60 | case HOME_PAGE_UNLOADED:
61 | case PROFILE_PAGE_UNLOADED:
62 | case PROFILE_FAVORITES_PAGE_UNLOADED:
63 | case SETTINGS_PAGE_UNLOADED:
64 | case LOGIN_PAGE_UNLOADED:
65 | case REGISTER_PAGE_UNLOADED:
66 | return { ...state, viewChangeCounter: state.viewChangeCounter + 1 };
67 | default:
68 | return state;
69 | }
70 | };
71 |
--------------------------------------------------------------------------------
/src/reducers/editor.js:
--------------------------------------------------------------------------------
1 | import {
2 | EDITOR_PAGE_LOADED,
3 | EDITOR_PAGE_UNLOADED,
4 | ARTICLE_SUBMITTED,
5 | ASYNC_START,
6 | ADD_TAG,
7 | REMOVE_TAG,
8 | UPDATE_FIELD_EDITOR
9 | } from '../constants/actionTypes';
10 |
11 | export default (state = {}, action) => {
12 | switch (action.type) {
13 | case EDITOR_PAGE_LOADED:
14 | return {
15 | ...state,
16 | articleSlug: action.payload ? action.payload.article.slug : '',
17 | title: action.payload ? action.payload.article.title : '',
18 | description: action.payload ? action.payload.article.description : '',
19 | body: action.payload ? action.payload.article.body : '',
20 | tagInput: '',
21 | tagList: action.payload ? action.payload.article.tagList : []
22 | };
23 | case EDITOR_PAGE_UNLOADED:
24 | return {};
25 | case ARTICLE_SUBMITTED:
26 | return {
27 | ...state,
28 | inProgress: null,
29 | errors: action.error ? action.payload.errors : null
30 | };
31 | case ASYNC_START:
32 | if (action.subtype === ARTICLE_SUBMITTED) {
33 | return { ...state, inProgress: true };
34 | }
35 | break;
36 | case ADD_TAG:
37 | return {
38 | ...state,
39 | tagList: state.tagList.concat([state.tagInput]),
40 | tagInput: ''
41 | };
42 | case REMOVE_TAG:
43 | return {
44 | ...state,
45 | tagList: state.tagList.filter(tag => tag !== action.tag)
46 | };
47 | case UPDATE_FIELD_EDITOR:
48 | return { ...state, [action.key]: action.value };
49 | default:
50 | return state;
51 | }
52 |
53 | return state;
54 | };
55 |
--------------------------------------------------------------------------------
/src/reducers/home.js:
--------------------------------------------------------------------------------
1 | import { HOME_PAGE_LOADED, HOME_PAGE_UNLOADED } from '../constants/actionTypes';
2 |
3 | export default (state = {}, action) => {
4 | switch (action.type) {
5 | case HOME_PAGE_LOADED:
6 | return {
7 | ...state,
8 | tags: action.payload[0].tags
9 | };
10 | case HOME_PAGE_UNLOADED:
11 | return {};
12 | default:
13 | return state;
14 | }
15 | };
16 |
--------------------------------------------------------------------------------
/src/reducers/profile.js:
--------------------------------------------------------------------------------
1 | import {
2 | PROFILE_PAGE_LOADED,
3 | PROFILE_PAGE_UNLOADED,
4 | FOLLOW_USER,
5 | UNFOLLOW_USER
6 | } from '../constants/actionTypes';
7 |
8 | export default (state = {}, action) => {
9 | switch (action.type) {
10 | case PROFILE_PAGE_LOADED:
11 | return {
12 | ...action.payload[0].profile
13 | };
14 | case PROFILE_PAGE_UNLOADED:
15 | return {};
16 | case FOLLOW_USER:
17 | case UNFOLLOW_USER:
18 | return {
19 | ...action.payload.profile
20 | };
21 | default:
22 | return state;
23 | }
24 | };
25 |
--------------------------------------------------------------------------------
/src/reducers/settings.js:
--------------------------------------------------------------------------------
1 | import {
2 | SETTINGS_SAVED,
3 | SETTINGS_PAGE_UNLOADED,
4 | ASYNC_START
5 | } from '../constants/actionTypes';
6 |
7 | export default (state = {}, action) => {
8 | switch (action.type) {
9 | case SETTINGS_SAVED:
10 | return {
11 | ...state,
12 | inProgress: false,
13 | errors: action.error ? action.payload.errors : null
14 | };
15 | case SETTINGS_PAGE_UNLOADED:
16 | return {};
17 | case ASYNC_START:
18 | return {
19 | ...state,
20 | inProgress: true
21 | };
22 | default:
23 | return state;
24 | }
25 | };
26 |
--------------------------------------------------------------------------------
/src/store.js:
--------------------------------------------------------------------------------
1 | import { applyMiddleware, createStore } from 'redux';
2 | import { createLogger } from 'redux-logger'
3 | import { composeWithDevTools } from 'redux-devtools-extension/developmentOnly';
4 | import { promiseMiddleware, localStorageMiddleware } from './middleware';
5 | import reducer from './reducer';
6 |
7 | import { routerMiddleware } from 'react-router-redux'
8 | import createHistory from 'history/createBrowserHistory';
9 |
10 | export const history = createHistory();
11 |
12 | // Build the middleware for intercepting and dispatching navigation actions
13 | const myRouterMiddleware = routerMiddleware(history);
14 |
15 | const getMiddleware = () => {
16 | if (process.env.NODE_ENV === 'production') {
17 | return applyMiddleware(myRouterMiddleware, promiseMiddleware, localStorageMiddleware);
18 | } else {
19 | // Enable additional logging in non-production environments.
20 | return applyMiddleware(myRouterMiddleware, promiseMiddleware, localStorageMiddleware, createLogger())
21 | }
22 | };
23 |
24 | export const store = createStore(
25 | reducer, composeWithDevTools(getMiddleware()));
26 |
--------------------------------------------------------------------------------