├── .env
├── .gitignore
├── .prettierrc
├── LICENSE
├── README.md
├── jsconfig.json
├── package-lock.json
├── package.json
├── project-logo.png
├── public
├── favicon.ico
└── index.html
├── src
├── App.js
├── agent.js
├── components
│ ├── ArticleList.js
│ ├── ArticlePreview.js
│ ├── Header.js
│ ├── ListErrors.js
│ ├── ListPagination.js
│ ├── LoadingSpinner.js
│ ├── PrivateRoute.js
│ └── RedError.js
├── index.js
├── pages
│ ├── Article
│ │ ├── ArticleActions.js
│ │ ├── ArticleMeta.js
│ │ ├── Comment.js
│ │ ├── CommentContainer.js
│ │ ├── CommentInput.js
│ │ ├── CommentList.js
│ │ ├── DeleteButton.js
│ │ └── index.js
│ ├── Editor.js
│ ├── Home
│ │ ├── Banner.js
│ │ ├── MainView.js
│ │ ├── Tags.js
│ │ └── index.js
│ ├── Login.js
│ ├── Profile.js
│ ├── Register.js
│ └── Settings.js
└── stores
│ ├── articlesStore.js
│ ├── authStore.js
│ ├── commentsStore.js
│ ├── commonStore.js
│ ├── editorStore.js
│ ├── profileStore.js
│ └── userStore.js
└── yarn.lock
/.env:
--------------------------------------------------------------------------------
1 | REACT_APP_DECORATORS=true
2 | NODE_PATH="src/"
--------------------------------------------------------------------------------
/.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 | npm-debug.log
15 | .idea
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "arrowParens": "always",
3 | "bracketSpacing": true,
4 | "jsxBracketSameLine": false,
5 | "trailingComma": "es5",
6 | "tabWidth": 2,
7 | "semi": false,
8 | "singleQuote": true
9 | }
10 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2017 Thinkster
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 | > ### React + Mobx 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.
4 |
5 | ### [Demo](https://react-mobx.realworld.io/) [RealWorld](https://github.com/gothinkster/realworld)
6 |
7 | 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.
8 |
9 | We're currently working on some docs for the codebase (explaining where functionality is located, how it works, etc) but most things should be self explanatory if you have a minimal understanding of React/Mobx.
10 |
11 |
12 | ## Getting started
13 |
14 | You can view a live demo over at https://react-mobx.realworld.io/
15 |
16 | To get the frontend running locally:
17 |
18 | - Clone this repo
19 | - `npm install` to install all req'd dependencies
20 | - `npm start` to start the local server (this project uses create-react-app)
21 |
22 |
23 | ### Making requests to the backend API
24 |
25 | 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.
26 |
27 | 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).
28 |
29 | 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. `localhost:3000/api`)
30 |
31 |
32 | ## Functionality overview
33 |
34 | 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://react-mobx.realworld.io/
35 |
36 | **General functionality:**
37 |
38 | - Authenticate users via JWT (login/signup pages + logout button on settings page)
39 | - CRU* users (sign up & settings page - no deleting required)
40 | - CRUD Articles
41 | - CR*D Comments on articles (no updating required)
42 | - GET and display paginated lists of articles
43 | - Favorite articles
44 | - Follow other users
45 |
46 | **The general page breakdown looks like this:**
47 |
48 | - Home page (URL: /#/ )
49 | - List of tags
50 | - List of articles pulled from either Feed, Global, or by Tag
51 | - Pagination for list of articles
52 | - Sign in/Sign up pages (URL: /#/login, /#/register )
53 | - Use JWT (store the token in localStorage)
54 | - Settings page (URL: /#/settings )
55 | - Editor page to create/edit articles (URL: /#/editor, /#/editor/article-slug-here )
56 | - Article page (URL: /#/article/article-slug-here )
57 | - Delete article button (only shown to article's author)
58 | - Render markdown from server client side
59 | - Comments section at bottom of page
60 | - Delete comment button (only shown to comment's author)
61 | - Profile page (URL: /#/@username, /#/@username/favorites )
62 | - Show basic user info
63 | - List of articles populated from author's created articles or author's favorited articles
64 |
65 |
66 |
67 | [](https://thinkster.io)
68 |
--------------------------------------------------------------------------------
/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "experimentalDecorators": true,
4 | "baseUrl": "src"
5 | },
6 | "exclude": ["node_modules"],
7 | "include": ["src"]
8 | }
9 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-mobx-realworld-example-app",
3 | "version": "0.1.0",
4 | "private": true,
5 | "homepage": "https://react-mobx.realworld.io",
6 | "license": "MIT",
7 | "devDependencies": {
8 | "custom-react-scripts": "0.2.0",
9 | "gh-pages": "^1.0.0"
10 | },
11 | "dependencies": {
12 | "history": "4.7.2",
13 | "marked": "^0.3.6",
14 | "mobx": "^3.2.2",
15 | "mobx-react": "^4.2.2",
16 | "promise.prototype.finally": "^3.0.0",
17 | "prop-types": "^15.5.10",
18 | "query-string": "5.0.0",
19 | "react": "^16.0.0",
20 | "react-dom": "^16.0.0",
21 | "react-router-dom": "4.2.2",
22 | "superagent": "^3.6.0",
23 | "superagent-promise": "^1.1.0"
24 | },
25 | "scripts": {
26 | "start": "react-scripts start",
27 | "build": "react-scripts build",
28 | "test": "react-scripts test --env=jsdom",
29 | "eject": "react-scripts eject",
30 | "predeploy": "npm run build",
31 | "deploy": "gh-pages -d build"
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/project-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gothinkster/react-mobx-realworld-example-app/c90ac8fa4b1f5c6292ca796b9c1a162efaef6e9b/project-logo.png
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gothinkster/react-mobx-realworld-example-app/c90ac8fa4b1f5c6292ca796b9c1a162efaef6e9b/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/App.js:
--------------------------------------------------------------------------------
1 | import Header from "./components/Header";
2 | import React from "react";
3 | import { Switch, Route, withRouter } from "react-router-dom";
4 | import { inject, observer } from "mobx-react";
5 | import PrivateRoute from "components/PrivateRoute";
6 |
7 | import Login from "pages/Login";
8 | import Home from "pages/Home";
9 | import Register from "pages/Register";
10 | import Article from "pages/Article";
11 | import Editor from "pages/Editor";
12 | import Profile from "pages/Profile";
13 | import Settings from "pages/Settings";
14 |
15 | @inject("userStore", "commonStore")
16 | @withRouter
17 | @observer
18 | export default class App extends React.Component {
19 | componentWillMount() {
20 | if (!this.props.commonStore.token) {
21 | this.props.commonStore.setAppLoaded();
22 | }
23 | }
24 |
25 | componentDidMount() {
26 | if (this.props.commonStore.token) {
27 | this.props.userStore
28 | .pullUser()
29 | .finally(() => this.props.commonStore.setAppLoaded());
30 | }
31 | }
32 |
33 | render() {
34 | if (this.props.commonStore.appLoaded) {
35 | return (
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 | );
50 | }
51 | return ;
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/src/agent.js:
--------------------------------------------------------------------------------
1 | import superagentPromise from 'superagent-promise';
2 | import _superagent from 'superagent';
3 | import commonStore from './stores/commonStore';
4 | import authStore from './stores/authStore';
5 |
6 | const superagent = superagentPromise(_superagent, global.Promise);
7 |
8 | const API_ROOT = 'https://conduit.productionready.io/api';
9 |
10 | const encode = encodeURIComponent;
11 |
12 | const handleErrors = err => {
13 | if (err && err.response && err.response.status === 401) {
14 | authStore.logout();
15 | }
16 | return err;
17 | };
18 |
19 | const responseBody = res => res.body;
20 |
21 | const tokenPlugin = req => {
22 | if (commonStore.token) {
23 | req.set('authorization', `Token ${commonStore.token}`);
24 | }
25 | };
26 |
27 | const requests = {
28 | del: url =>
29 | superagent
30 | .del(`${API_ROOT}${url}`)
31 | .use(tokenPlugin)
32 | .end(handleErrors)
33 | .then(responseBody),
34 | get: url =>
35 | superagent
36 | .get(`${API_ROOT}${url}`)
37 | .use(tokenPlugin)
38 | .end(handleErrors)
39 | .then(responseBody),
40 | put: (url, body) =>
41 | superagent
42 | .put(`${API_ROOT}${url}`, body)
43 | .use(tokenPlugin)
44 | .end(handleErrors)
45 | .then(responseBody),
46 | post: (url, body) =>
47 | superagent
48 | .post(`${API_ROOT}${url}`, body)
49 | .use(tokenPlugin)
50 | .end(handleErrors)
51 | .then(responseBody),
52 | };
53 |
54 | const Auth = {
55 | current: () =>
56 | requests.get('/user'),
57 | login: (email, password) =>
58 | requests.post('/users/login', { user: { email, password } }),
59 | register: (username, email, password) =>
60 | requests.post('/users', { user: { username, email, password } }),
61 | save: user =>
62 | requests.put('/user', { user })
63 | };
64 |
65 | const Tags = {
66 | getAll: () => requests.get('/tags')
67 | };
68 |
69 | const limit = (count, p) => `limit=${count}&offset=${p ? p * count : 0}`;
70 | const omitSlug = article => Object.assign({}, article, { slug: undefined })
71 |
72 | const Articles = {
73 | all: (page, lim = 10) =>
74 | requests.get(`/articles?${limit(lim, page)}`),
75 | byAuthor: (author, page, query) =>
76 | requests.get(`/articles?author=${encode(author)}&${limit(5, page)}`),
77 | byTag: (tag, page, lim = 10) =>
78 | requests.get(`/articles?tag=${encode(tag)}&${limit(lim, page)}`),
79 | del: slug =>
80 | requests.del(`/articles/${slug}`),
81 | favorite: slug =>
82 | requests.post(`/articles/${slug}/favorite`),
83 | favoritedBy: (author, page) =>
84 | requests.get(`/articles?favorited=${encode(author)}&${limit(5, page)}`),
85 | feed: () =>
86 | requests.get('/articles/feed?limit=10&offset=0'),
87 | get: slug =>
88 | requests.get(`/articles/${slug}`),
89 | unfavorite: slug =>
90 | requests.del(`/articles/${slug}/favorite`),
91 | update: article =>
92 | requests.put(`/articles/${article.slug}`, { article: omitSlug(article) }),
93 | create: article =>
94 | requests.post('/articles', { article })
95 | };
96 |
97 | const Comments = {
98 | create: (slug, comment) =>
99 | requests.post(`/articles/${slug}/comments`, { comment }),
100 | delete: (slug, commentId) =>
101 | requests.del(`/articles/${slug}/comments/${commentId}`),
102 | forArticle: slug =>
103 | requests.get(`/articles/${slug}/comments`)
104 | };
105 |
106 | const Profile = {
107 | follow: username =>
108 | requests.post(`/profiles/${username}/follow`),
109 | get: username =>
110 | requests.get(`/profiles/${username}`),
111 | unfollow: username =>
112 | requests.del(`/profiles/${username}/follow`)
113 | };
114 |
115 | export default {
116 | Articles,
117 | Auth,
118 | Comments,
119 | Profile,
120 | Tags,
121 | };
122 |
--------------------------------------------------------------------------------
/src/components/ArticleList.js:
--------------------------------------------------------------------------------
1 | import ArticlePreview from './ArticlePreview';
2 | import ListPagination from './ListPagination';
3 | import LoadingSpinner from './LoadingSpinner';
4 | import React from 'react';
5 |
6 | const ArticleList = props => {
7 | if (props.loading && props.articles.length === 0) {
8 | return (
9 |
10 | );
11 | }
12 |
13 | if (props.articles.length === 0) {
14 | return (
15 |
16 | No articles are here... yet.
17 |
18 | );
19 | }
20 |
21 | return (
22 |
23 | {
24 | props.articles.map(article => {
25 | return (
26 |
27 | );
28 | })
29 | }
30 |
31 |
36 |
37 | );
38 | };
39 |
40 | export default ArticleList;
41 |
--------------------------------------------------------------------------------
/src/components/ArticlePreview.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Link } from 'react-router-dom';
3 | import { inject, observer } from 'mobx-react';
4 |
5 | const FAVORITED_CLASS = 'btn btn-sm btn-primary';
6 | const NOT_FAVORITED_CLASS = 'btn btn-sm btn-outline-primary';
7 |
8 |
9 | @inject('articlesStore')
10 | @observer
11 | export default class ArticlePreview extends React.Component {
12 |
13 | handleClickFavorite = ev => {
14 | ev.preventDefault();
15 | const { articlesStore, article } = this.props;
16 | if (article.favorited) {
17 | articlesStore.unmakeFavorite(article.slug);
18 | } else {
19 | articlesStore.makeFavorite(article.slug);
20 | }
21 | };
22 |
23 | render() {
24 | const { article } = this.props;
25 | const favoriteButtonClass = article.favorited ? FAVORITED_CLASS : NOT_FAVORITED_CLASS;
26 |
27 | return (
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 | {article.author.username}
37 |
38 |
39 | {new Date(article.createdAt).toDateString()}
40 |
41 |
42 |
43 |
44 |
45 | {article.favoritesCount}
46 |
47 |
48 |
49 |
50 |
51 |
{article.title}
52 |
{article.description}
53 |
Read more...
54 |
55 | {
56 | article.tagList.map(tag => {
57 | return (
58 |
59 | {tag}
60 |
61 | )
62 | })
63 | }
64 |
65 |
66 |
67 | );
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/src/components/Header.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Link } from 'react-router-dom';
3 | import { inject, observer } from 'mobx-react';
4 |
5 | const LoggedOutView = props => {
6 | if (!props.currentUser) {
7 | return (
8 |
9 |
10 |
11 |
12 | Home
13 |
14 |
15 |
16 |
17 |
18 | Sign in
19 |
20 |
21 |
22 |
23 |
24 | Sign up
25 |
26 |
27 |
28 |
29 | );
30 | }
31 | return null;
32 | };
33 |
34 | const LoggedInView = props => {
35 | if (props.currentUser) {
36 | return (
37 |
38 |
39 |
40 |
41 | Home
42 |
43 |
44 |
45 |
46 |
47 | New Post
48 |
49 |
50 |
51 |
52 |
53 | Settings
54 |
55 |
56 |
57 |
58 |
62 |
63 | {props.currentUser.username}
64 |
65 |
66 |
67 |
68 | );
69 | }
70 |
71 | return null;
72 | };
73 |
74 | @inject('userStore', 'commonStore')
75 | @observer
76 | class Header extends React.Component {
77 | render() {
78 | return (
79 |
80 |
81 |
82 |
83 | {this.props.commonStore.appName.toLowerCase()}
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 | );
92 | }
93 | }
94 |
95 | export default Header;
96 |
--------------------------------------------------------------------------------
/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 |
3 | const ListPagination = props => {
4 | if (props.totalPagesCount < 2) {
5 | return null;
6 | }
7 |
8 | const range = [];
9 | for (let i = 0; i < props.totalPagesCount; ++i) {
10 | range.push(i);
11 | }
12 |
13 | return (
14 |
15 |
16 |
17 | {
18 | range.map(v => {
19 | const isCurrent = v === props.currentPage;
20 | const onClick = ev => {
21 | ev.preventDefault();
22 | props.onSetPage(v);
23 | };
24 | return (
25 |
30 |
31 | {v + 1}
32 |
33 |
34 | );
35 | })
36 | }
37 |
38 |
39 |
40 | );
41 | };
42 |
43 | export default ListPagination;
44 |
--------------------------------------------------------------------------------
/src/components/LoadingSpinner.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | const style = {
4 | borderRadius: '50%',
5 | width: '40px',
6 | height: '40px',
7 | margin: '90px auto',
8 | position: 'relative',
9 | borderTop: '3px solid rgba(0, 0, 0, 0.1)',
10 | borderRight: '3px solid rgba(0, 0, 0, 0.1)',
11 | borderBottom: '3px solid rgba(0, 0, 0, 0.1)',
12 | borderLeft: '3px solid #818a91',
13 | transform: 'translateZ(0)',
14 | animation: 'loading-spinner 0.5s infinite linear',
15 | };
16 |
17 | export default class LoadingSpinner extends React.Component {
18 | render() {
19 | return (
20 |
21 |
29 |
30 | );
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/src/components/PrivateRoute.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Route, Redirect } from 'react-router-dom';
3 | import { inject, observer } from 'mobx-react';
4 |
5 | @inject('userStore', 'commonStore')
6 | @observer
7 | export default class PrivateRoute extends React.Component {
8 | render() {
9 | const { userStore, ...restProps } = this.props;
10 | if (userStore.currentUser) return ;
11 | return ;
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/src/components/RedError.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | const wrapperStyle = {
4 | display: 'flex',
5 | justifyContent: 'center',
6 | };
7 |
8 | const errorStyle = {
9 | display: 'inline-block',
10 | margin: '20px auto',
11 | borderRadius: '4px',
12 | padding: '8px 15px',
13 | color: 'rgb(240, 45, 45)',
14 | fontWeight: 'bold',
15 | backgroundColor: 'rgba(240, 45, 45, 0.1)'
16 | };
17 |
18 | export default class RedError extends React.Component {
19 | render() {
20 | return (
21 |
22 |
23 | {this.props.message}
24 |
25 |
26 | );
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import ReactDOM from "react-dom";
2 | import promiseFinally from "promise.prototype.finally";
3 | import React from "react";
4 | import { HashRouter } from "react-router-dom";
5 | import { useStrict } from "mobx";
6 | import { Provider } from "mobx-react";
7 |
8 | import App from "./App";
9 |
10 | import articlesStore from "./stores/articlesStore";
11 | import commentsStore from "./stores/commentsStore";
12 | import authStore from "./stores/authStore";
13 | import commonStore from "./stores/commonStore";
14 | import editorStore from "./stores/editorStore";
15 | import userStore from "./stores/userStore";
16 | import profileStore from "./stores/profileStore";
17 |
18 | const stores = {
19 | articlesStore,
20 | commentsStore,
21 | authStore,
22 | commonStore,
23 | editorStore,
24 | userStore,
25 | profileStore
26 | };
27 |
28 | // For easier debugging
29 | window._____APP_STATE_____ = stores;
30 |
31 | promiseFinally.shim();
32 | useStrict(true);
33 |
34 | ReactDOM.render(
35 |
36 |
37 |
38 |
39 | ,
40 | document.getElementById("root")
41 | );
42 |
--------------------------------------------------------------------------------
/src/pages/Article/ArticleActions.js:
--------------------------------------------------------------------------------
1 | import { Link } from 'react-router-dom';
2 | import React from 'react';
3 |
4 | const ArticleActions = props => {
5 | const article = props.article;
6 | const handleDelete = () => props.onDelete(article.slug);
7 |
8 | if (props.canModify) {
9 | return (
10 |
11 |
12 |
16 | Edit Article
17 |
18 |
19 |
20 | Delete Article
21 |
22 |
23 |
24 | );
25 | }
26 |
27 | return (
28 |
29 | );
30 | };
31 |
32 | export default ArticleActions;
33 |
--------------------------------------------------------------------------------
/src/pages/Article/ArticleMeta.js:
--------------------------------------------------------------------------------
1 | import ArticleActions from './ArticleActions';
2 | import { Link } from 'react-router-dom';
3 | import React from 'react';
4 | import { observer } from 'mobx-react';
5 |
6 | const ArticleMeta = observer(props => {
7 | const article = props.article;
8 | return (
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 | {article.author.username}
17 |
18 |
19 | {new Date(article.createdAt).toDateString()}
20 |
21 |
22 |
23 |
24 |
25 | );
26 | });
27 |
28 | export default ArticleMeta;
29 |
--------------------------------------------------------------------------------
/src/pages/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 |
19 |
20 |
21 |
22 |
26 | {comment.author.username}
27 |
28 |
29 | {new Date(comment.createdAt).toDateString()}
30 |
31 |
37 |
38 |
39 | );
40 | };
41 |
42 | export default Comment;
43 |
--------------------------------------------------------------------------------
/src/pages/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 |
21 |
22 | );
23 | } else {
24 | return (
25 |
26 |
27 | Sign in
28 | or
29 | sign up
30 | to add comments on this article.
31 |
32 |
33 |
39 |
40 | );
41 | }
42 | };
43 |
44 | export default CommentContainer;
45 |
--------------------------------------------------------------------------------
/src/pages/Article/CommentInput.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { inject } from 'mobx-react';
3 |
4 | @inject('commentsStore')
5 | export default class CommentInput extends React.Component {
6 | constructor() {
7 | super();
8 | this.state = {
9 | body: ''
10 | };
11 |
12 | this.handleBodyChange = ev => {
13 | this.setState({ body: ev.target.value });
14 | };
15 |
16 | this.createComment = ev => {
17 | ev.preventDefault();
18 | this.props.commentsStore.createComment({ body: this.state.body })
19 | .then(() => this.setState({ body: '' }));
20 | };
21 | }
22 |
23 | render() {
24 | const { isCreatingComment } = this.props.commentsStore;
25 | return (
26 |
50 | );
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/src/pages/Article/CommentList.js:
--------------------------------------------------------------------------------
1 | import Comment from './Comment';
2 | import React from 'react';
3 | import { observer } from 'mobx-react';
4 |
5 | const CommentList = observer(props => {
6 | return (
7 |
8 | {
9 | props.comments.map(comment => {
10 | return (
11 |
18 | );
19 | })
20 | }
21 |
22 | );
23 | });
24 |
25 | export default CommentList;
26 |
--------------------------------------------------------------------------------
/src/pages/Article/DeleteButton.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | const DeleteButton = props => {
4 | const handleClick = () => props.onDelete(props.commentId);
5 |
6 | if (props.show) {
7 | return (
8 |
9 |
10 |
11 | );
12 | }
13 | return null;
14 | };
15 |
16 | export default DeleteButton;
17 |
--------------------------------------------------------------------------------
/src/pages/Article/index.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { inject, observer } from "mobx-react";
3 | import { withRouter } from "react-router-dom";
4 | import marked from "marked";
5 |
6 | import RedError from "components/RedError";
7 | import ArticleMeta from "./ArticleMeta";
8 | import CommentContainer from "./CommentContainer";
9 |
10 | @inject("articlesStore", "userStore", "commentsStore")
11 | @withRouter
12 | @observer
13 | export default class Article extends React.Component {
14 | componentDidMount() {
15 | const slug = this.props.match.params.id;
16 | this.props.articlesStore.loadArticle(slug, { acceptCached: true });
17 | this.props.commentsStore.setArticleSlug(slug);
18 | this.props.commentsStore.loadComments();
19 | }
20 |
21 | handleDeleteArticle = slug => {
22 | this.props.articlesStore
23 | .deleteArticle(slug)
24 | .then(() => this.props.history.replace("/"));
25 | };
26 |
27 | handleDeleteComment = id => {
28 | this.props.commentsStore.deleteComment(id);
29 | };
30 |
31 | render() {
32 | const slug = this.props.match.params.id;
33 | const { currentUser } = this.props.userStore;
34 | const { comments, commentErrors } = this.props.commentsStore;
35 | const article = this.props.articlesStore.getArticle(slug);
36 |
37 | if (!article) return ;
38 |
39 | const markup = { __html: marked(article.body, { sanitize: true }) };
40 | const canModify =
41 | currentUser && currentUser.username === article.author.username;
42 | return (
43 |
44 |
45 |
46 |
{article.title}
47 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 | {article.tagList.map(tag => {
62 | return (
63 |
64 | {tag}
65 |
66 | );
67 | })}
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
84 |
85 |
86 |
87 | );
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/src/pages/Editor.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { inject, observer } from "mobx-react";
3 | import { withRouter } from "react-router-dom";
4 |
5 | import ListErrors from "components/ListErrors";
6 |
7 | @inject("editorStore")
8 | @withRouter
9 | @observer
10 | export default class Editor extends React.Component {
11 | state = {
12 | tagInput: ""
13 | };
14 |
15 | componentWillMount() {
16 | this.props.editorStore.setArticleSlug(this.props.match.params.slug);
17 | }
18 |
19 | componentDidMount() {
20 | this.props.editorStore.loadInitialData();
21 | }
22 |
23 | componentDidUpdate(prevProps) {
24 | if (this.props.match.params.slug !== prevProps.match.params.slug) {
25 | this.props.editorStore.setArticleSlug(this.props.match.params.slug);
26 | this.props.editorStore.loadInitialData();
27 | }
28 | }
29 |
30 | changeTitle = e => this.props.editorStore.setTitle(e.target.value);
31 | changeDescription = e =>
32 | this.props.editorStore.setDescription(e.target.value);
33 | changeBody = e => this.props.editorStore.setBody(e.target.value);
34 | changeTagInput = e => this.setState({ tagInput: e.target.value });
35 |
36 | handleTagInputKeyDown = ev => {
37 | switch (ev.keyCode) {
38 | case 13: // Enter
39 | case 9: // Tab
40 | case 188: // ,
41 | if (ev.keyCode !== 9) ev.preventDefault();
42 | this.handleAddTag();
43 | break;
44 | default:
45 | break;
46 | }
47 | };
48 |
49 | handleAddTag = () => {
50 | if (this.state.tagInput) {
51 | this.props.editorStore.addTag(this.state.tagInput.trim());
52 | this.setState({ tagInput: "" });
53 | }
54 | };
55 |
56 | handleRemoveTag = tag => {
57 | if (this.props.editorStore.inProgress) return;
58 | this.props.editorStore.removeTag(tag);
59 | };
60 |
61 | submitForm = ev => {
62 | ev.preventDefault();
63 | const { editorStore } = this.props;
64 | editorStore.submit().then(article => {
65 | editorStore.reset();
66 | this.props.history.replace(`/article/${article.slug}`);
67 | });
68 | };
69 |
70 | render() {
71 | const {
72 | inProgress,
73 | errors,
74 | title,
75 | description,
76 | body,
77 | tagList
78 | } = this.props.editorStore;
79 |
80 | return (
81 |
163 | );
164 | }
165 | }
166 |
--------------------------------------------------------------------------------
/src/pages/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/pages/Home/MainView.js:
--------------------------------------------------------------------------------
1 | import ArticleList from "components/ArticleList";
2 | import React from "react";
3 | import { inject, observer } from "mobx-react";
4 | import { withRouter, NavLink } from "react-router-dom";
5 | import { parse as qsParse } from "query-string";
6 |
7 | const YourFeedTab = props => {
8 | if (props.currentUser) {
9 | return (
10 |
11 | {
14 | return location.search.match("tab=feed") ? 1 : 0;
15 | }}
16 | to={{
17 | pathname: "/",
18 | search: "?tab=feed"
19 | }}
20 | >
21 | Your Feed
22 |
23 |
24 | );
25 | }
26 | return null;
27 | };
28 |
29 | const GlobalFeedTab = props => {
30 | return (
31 |
32 | {
35 | return !location.search.match(/tab=(feed|tag)/) ? 1 : 0;
36 | }}
37 | to={{
38 | pathname: "/",
39 | search: "?tab=all"
40 | }}
41 | >
42 | Global Feed
43 |
44 |
45 | );
46 | };
47 |
48 | const TagFilterTab = props => {
49 | if (!props.tag) {
50 | return null;
51 | }
52 |
53 | return (
54 |
55 |
56 | {props.tag}
57 |
58 |
59 | );
60 | };
61 |
62 | @inject("articlesStore", "commonStore", "userStore")
63 | @withRouter
64 | @observer
65 | export default class MainView extends React.Component {
66 | componentWillMount() {
67 | this.props.articlesStore.setPredicate(this.getPredicate());
68 | }
69 |
70 | componentDidMount() {
71 | this.props.articlesStore.loadArticles();
72 | }
73 |
74 | componentDidUpdate(previousProps) {
75 | if (
76 | this.getTab(this.props) !== this.getTab(previousProps) ||
77 | this.getTag(this.props) !== this.getTag(previousProps)
78 | ) {
79 | this.props.articlesStore.setPredicate(this.getPredicate());
80 | this.props.articlesStore.loadArticles();
81 | }
82 | }
83 |
84 | getTag(props = this.props) {
85 | return qsParse(props.location.search).tag || "";
86 | }
87 |
88 | getTab(props = this.props) {
89 | return qsParse(props.location.search).tab || "all";
90 | }
91 |
92 | getPredicate(props = this.props) {
93 | switch (this.getTab(props)) {
94 | case "feed":
95 | return { myFeed: true };
96 | case "tag":
97 | return { tag: qsParse(props.location.search).tag };
98 | default:
99 | return {};
100 | }
101 | }
102 |
103 | handleTabChange = tab => {
104 | if (this.props.location.query.tab === tab) return;
105 | this.props.router.push({ ...this.props.location, query: { tab } });
106 | };
107 |
108 | handleSetPage = page => {
109 | this.props.articlesStore.setPage(page);
110 | this.props.articlesStore.loadArticles();
111 | };
112 |
113 | render() {
114 | const { currentUser } = this.props.userStore;
115 | const {
116 | articles,
117 | isLoading,
118 | page,
119 | totalPagesCount
120 | } = this.props.articlesStore;
121 |
122 | return (
123 |
124 |
125 |
126 |
127 |
128 |
129 |
130 |
131 |
132 |
133 |
134 |
141 |
142 | );
143 | }
144 | }
145 |
--------------------------------------------------------------------------------
/src/pages/Home/Tags.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Link } from "react-router-dom";
3 | import LoadingSpinner from "components/LoadingSpinner";
4 |
5 | const Tags = props => {
6 | const tags = props.tags;
7 | if (tags) {
8 | return (
9 |
10 | {tags.map(tag => {
11 | return (
12 |
20 | {tag}
21 |
22 | );
23 | })}
24 |
25 | );
26 | } else {
27 | return ;
28 | }
29 | };
30 |
31 | export default Tags;
32 |
--------------------------------------------------------------------------------
/src/pages/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 { inject, observer } from 'mobx-react';
6 | import { withRouter } from 'react-router-dom';
7 |
8 | @inject('commonStore')
9 | @withRouter
10 | @observer
11 | export default class Home extends React.Component {
12 | componentDidMount() {
13 | this.props.commonStore.loadTags();
14 | }
15 |
16 | render() {
17 | const { tags, token, appName } = this.props.commonStore;
18 | return (
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
Popular Tags
31 |
32 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 | );
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/src/pages/Login.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { withRouter, Link } from "react-router-dom";
3 | import ListErrors from "components/ListErrors";
4 | import { inject, observer } from "mobx-react";
5 |
6 | @inject("authStore")
7 | @withRouter
8 | @observer
9 | export default class Login extends React.Component {
10 | componentWillUnmount() {
11 | this.props.authStore.reset();
12 | }
13 |
14 | handleEmailChange = e => {
15 | this.props.authStore.setEmail(e.target.value);
16 | };
17 |
18 | handlePasswordChange = e => {
19 | this.props.authStore.setPassword(e.target.value);
20 | };
21 |
22 | handleSubmitForm = e => {
23 | e.preventDefault();
24 | this.props.authStore.login().then(() => this.props.history.replace("/"));
25 | };
26 |
27 | render() {
28 | const { values, errors, inProgress } = this.props.authStore;
29 |
30 | return (
31 |
32 |
33 |
34 |
35 |
Sign In
36 |
37 | Need an account?
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
52 |
53 |
54 |
55 |
62 |
63 |
64 |
69 | Sign in
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 | );
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/src/pages/Profile.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { NavLink, Link, withRouter } from "react-router-dom";
3 | import { inject, observer } from "mobx-react";
4 |
5 | import RedError from "components/RedError";
6 | import LoadingSpinner from "components/LoadingSpinner";
7 | import ArticleList from "components/ArticleList";
8 |
9 | const EditProfileSettings = props => {
10 | if (props.isUser) {
11 | return (
12 |
16 | Edit Profile Settings
17 |
18 | );
19 | }
20 | return null;
21 | };
22 |
23 | const FollowUserButton = props => {
24 | if (props.isUser) {
25 | return null;
26 | }
27 |
28 | let classes = "btn btn-sm action-btn";
29 | if (props.following) {
30 | classes += " btn-secondary";
31 | } else {
32 | classes += " btn-outline-secondary";
33 | }
34 |
35 | const handleClick = ev => {
36 | ev.preventDefault();
37 | if (props.following) {
38 | props.unfollow(props.username);
39 | } else {
40 | props.follow(props.username);
41 | }
42 | };
43 |
44 | return (
45 |
46 |
47 |
48 | {props.following ? "Unfollow" : "Follow"} {props.username}
49 |
50 | );
51 | };
52 |
53 | @inject("articlesStore", "profileStore", "userStore")
54 | @withRouter
55 | @observer
56 | export default class Profile extends React.Component {
57 | componentWillMount() {
58 | this.props.articlesStore.setPredicate(this.getPredicate());
59 | }
60 |
61 | componentDidMount() {
62 | this.props.profileStore.loadProfile(this.props.match.params.username);
63 | this.props.articlesStore.loadArticles();
64 | }
65 |
66 | componentDidUpdate(previousProps) {
67 | if (this.props.location !== previousProps.location) {
68 | this.props.profileStore.loadProfile(this.props.match.params.username);
69 | this.props.articlesStore.setPredicate(this.getPredicate());
70 | this.props.articlesStore.loadArticles();
71 | }
72 | }
73 |
74 | getTab() {
75 | if (/\/favorites/.test(this.props.location.pathname)) return "favorites";
76 | return "all";
77 | }
78 |
79 | getPredicate() {
80 | switch (this.getTab()) {
81 | case "favorites":
82 | return { favoritedBy: this.props.match.params.username };
83 | default:
84 | return { author: this.props.match.params.username };
85 | }
86 | }
87 |
88 | handleFollow = () => this.props.profileStore.follow();
89 | handleUnfollow = () => this.props.profileStore.unfollow();
90 |
91 | handleSetPage = page => {
92 | this.props.articlesStore.setPage(page);
93 | this.props.articlesStore.loadArticles();
94 | };
95 |
96 | renderTabs() {
97 | const { profile } = this.props.profileStore;
98 | return (
99 |
100 |
101 | {
104 | return location.pathname.match("/favorites") ? 0 : 1;
105 | }}
106 | to={`/@${profile.username}`}
107 | >
108 | My Articles
109 |
110 |
111 |
112 |
113 |
114 | Favorited Articles
115 |
116 |
117 |
118 | );
119 | }
120 |
121 | render() {
122 | const { profileStore, articlesStore, userStore } = this.props;
123 | const { profile, isLoadingProfile } = profileStore;
124 | const { currentUser } = userStore;
125 |
126 | if (isLoadingProfile && !profile) return ;
127 | if (!profile) return ;
128 |
129 | const isUser = currentUser && profile.username === currentUser.username;
130 |
131 | return (
132 |
133 |
134 |
135 |
136 |
137 |
138 |
{profile.username}
139 |
{profile.bio}
140 |
141 |
142 |
149 |
150 |
151 |
152 |
153 |
154 |
155 |
156 |
157 |
{this.renderTabs()}
158 |
159 |
165 |
166 |
167 |
168 |
169 | );
170 | }
171 | }
172 |
173 | export { Profile };
174 |
--------------------------------------------------------------------------------
/src/pages/Register.js:
--------------------------------------------------------------------------------
1 | import { Link } from "react-router-dom";
2 | import ListErrors from "components/ListErrors";
3 | import React from "react";
4 | import { inject, observer } from "mobx-react";
5 |
6 | @inject("authStore")
7 | @observer
8 | export default class Register extends React.Component {
9 | componentWillUnmount() {
10 | this.props.authStore.reset();
11 | }
12 |
13 | handleUsernameChange = e => this.props.authStore.setUsername(e.target.value);
14 | handleEmailChange = e => this.props.authStore.setEmail(e.target.value);
15 | handlePasswordChange = e => this.props.authStore.setPassword(e.target.value);
16 | handleSubmitForm = e => {
17 | e.preventDefault();
18 | this.props.authStore.register().then(() => this.props.history.replace("/"));
19 | };
20 |
21 | render() {
22 | const { values, errors, inProgress } = this.props.authStore;
23 |
24 | return (
25 |
81 | );
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/src/pages/Settings.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { withRouter } from "react-router-dom";
3 | import { inject, observer } from "mobx-react";
4 |
5 | import ListErrors from "components/ListErrors";
6 |
7 | @inject("userStore")
8 | @observer
9 | class SettingsForm extends React.Component {
10 | constructor() {
11 | super();
12 |
13 | this.state = {
14 | image: "",
15 | username: "",
16 | bio: "",
17 | email: "",
18 | password: ""
19 | };
20 |
21 | this.updateState = field => ev => {
22 | const state = this.state;
23 | const newState = Object.assign({}, state, { [field]: ev.target.value });
24 | this.setState(newState);
25 | };
26 |
27 | this.submitForm = ev => {
28 | ev.preventDefault();
29 |
30 | const user = Object.assign({}, this.state);
31 | if (!user.password) {
32 | delete user.password;
33 | }
34 |
35 | this.props.onSubmitForm(user);
36 | };
37 | }
38 |
39 | componentWillMount() {
40 | if (this.props.userStore.currentUser) {
41 | Object.assign(this.state, {
42 | image: this.props.userStore.currentUser.image || "",
43 | username: this.props.userStore.currentUser.username,
44 | bio: this.props.userStore.currentUser.bio || "",
45 | email: this.props.userStore.currentUser.email
46 | });
47 | }
48 | }
49 |
50 | render() {
51 | return (
52 |
53 |
54 |
55 |
62 |
63 |
64 |
65 |
72 |
73 |
74 |
75 |
82 |
83 |
84 |
85 |
92 |
93 |
94 |
95 |
102 |
103 |
104 |
109 | Update Settings
110 |
111 |
112 |
113 | );
114 | }
115 | }
116 |
117 | @inject("userStore", "authStore")
118 | @withRouter
119 | @observer
120 | class Settings extends React.Component {
121 | handleClickLogout = () =>
122 | this.props.authStore.logout().then(() => this.props.history.replace("/"));
123 |
124 | render() {
125 | return (
126 |
127 |
128 |
129 |
130 |
Your Settings
131 |
132 |
133 |
134 | this.props.userStore.updateUser(user)}
137 | />
138 |
139 |
140 |
141 |
145 | Or click here to logout.
146 |
147 |
148 |
149 |
150 |
151 | );
152 | }
153 | }
154 |
155 | export default Settings;
156 |
--------------------------------------------------------------------------------
/src/stores/articlesStore.js:
--------------------------------------------------------------------------------
1 | import { observable, action, computed } from 'mobx';
2 | import agent from '../agent';
3 |
4 | const LIMIT = 10;
5 |
6 | export class ArticlesStore {
7 |
8 | @observable isLoading = false;
9 | @observable page = 0;
10 | @observable totalPagesCount = 0;
11 | @observable articlesRegistry = observable.map();
12 | @observable predicate = {};
13 |
14 | @computed get articles() {
15 | return this.articlesRegistry.values();
16 | };
17 |
18 | clear() {
19 | this.articlesRegistry.clear();
20 | this.page = 0;
21 | }
22 |
23 | getArticle(slug) {
24 | return this.articlesRegistry.get(slug);
25 | }
26 |
27 | @action setPage(page) {
28 | this.page = page;
29 | }
30 |
31 | @action setPredicate(predicate) {
32 | if (JSON.stringify(predicate) === JSON.stringify(this.predicate)) return;
33 | this.clear();
34 | this.predicate = predicate;
35 | }
36 |
37 | $req() {
38 | if (this.predicate.myFeed) return agent.Articles.feed(this.page, LIMIT);
39 | if (this.predicate.favoritedBy) return agent.Articles.favoritedBy(this.predicate.favoritedBy, this.page, LIMIT);
40 | if (this.predicate.tag) return agent.Articles.byTag(this.predicate.tag, this.page, LIMIT);
41 | if (this.predicate.author) return agent.Articles.byAuthor(this.predicate.author, this.page, LIMIT);
42 | return agent.Articles.all(this.page, LIMIT, this.predicate);
43 | }
44 |
45 | @action loadArticles() {
46 | this.isLoading = true;
47 | return this.$req()
48 | .then(action(({ articles, articlesCount }) => {
49 | this.articlesRegistry.clear();
50 | articles.forEach(article => this.articlesRegistry.set(article.slug, article));
51 | this.totalPagesCount = Math.ceil(articlesCount / LIMIT);
52 | }))
53 | .finally(action(() => { this.isLoading = false; }));
54 | }
55 |
56 | @action loadArticle(slug, { acceptCached = false } = {}) {
57 | if (acceptCached) {
58 | const article = this.getArticle(slug);
59 | if (article) return Promise.resolve(article);
60 | }
61 | this.isLoading = true;
62 | return agent.Articles.get(slug)
63 | .then(action(({ article }) => {
64 | this.articlesRegistry.set(article.slug, article);
65 | return article;
66 | }))
67 | .finally(action(() => { this.isLoading = false; }));
68 | }
69 |
70 | @action makeFavorite(slug) {
71 | const article = this.getArticle(slug);
72 | if (article && !article.favorited) {
73 | article.favorited = true;
74 | article.favoritesCount++;
75 | return agent.Articles.favorite(slug)
76 | .catch(action(err => {
77 | article.favorited = false;
78 | article.favoritesCount--;
79 | throw err;
80 | }));
81 | }
82 | return Promise.resolve();
83 | }
84 |
85 | @action unmakeFavorite(slug) {
86 | const article = this.getArticle(slug);
87 | if (article && article.favorited) {
88 | article.favorited = false;
89 | article.favoritesCount--;
90 | return agent.Articles.unfavorite(slug)
91 | .catch(action(err => {
92 | article.favorited = true;
93 | article.favoritesCount++;
94 | throw err;
95 | }));
96 | }
97 | return Promise.resolve();
98 | }
99 |
100 | @action createArticle(article) {
101 | return agent.Articles.create(article)
102 | .then(({ article }) => {
103 | this.articlesRegistry.set(article.slug, article);
104 | return article;
105 | })
106 | }
107 |
108 | @action updateArticle(data) {
109 | return agent.Articles.update(data)
110 | .then(({ article }) => {
111 | this.articlesRegistry.set(article.slug, article);
112 | return article;
113 | })
114 | }
115 |
116 | @action deleteArticle(slug) {
117 | this.articlesRegistry.delete(slug);
118 | return agent.Articles.del(slug)
119 | .catch(action(err => { this.loadArticles(); throw err; }));
120 | }
121 | }
122 |
123 | export default new ArticlesStore();
124 |
--------------------------------------------------------------------------------
/src/stores/authStore.js:
--------------------------------------------------------------------------------
1 | import { observable, action } from 'mobx';
2 | import agent from '../agent';
3 | import userStore from './userStore';
4 | import commonStore from './commonStore';
5 |
6 | class AuthStore {
7 | @observable inProgress = false;
8 | @observable errors = undefined;
9 |
10 | @observable values = {
11 | username: '',
12 | email: '',
13 | password: '',
14 | };
15 |
16 | @action setUsername(username) {
17 | this.values.username = username;
18 | }
19 |
20 | @action setEmail(email) {
21 | this.values.email = email;
22 | }
23 |
24 | @action setPassword(password) {
25 | this.values.password = password;
26 | }
27 |
28 | @action reset() {
29 | this.values.username = '';
30 | this.values.email = '';
31 | this.values.password = '';
32 | }
33 |
34 | @action login() {
35 | this.inProgress = true;
36 | this.errors = undefined;
37 | return agent.Auth.login(this.values.email, this.values.password)
38 | .then(({ user }) => commonStore.setToken(user.token))
39 | .then(() => userStore.pullUser())
40 | .catch(action((err) => {
41 | this.errors = err.response && err.response.body && err.response.body.errors;
42 | throw err;
43 | }))
44 | .finally(action(() => { this.inProgress = false; }));
45 | }
46 |
47 | @action register() {
48 | this.inProgress = true;
49 | this.errors = undefined;
50 | return agent.Auth.register(this.values.username, this.values.email, this.values.password)
51 | .then(({ user }) => commonStore.setToken(user.token))
52 | .then(() => userStore.pullUser())
53 | .catch(action((err) => {
54 | this.errors = err.response && err.response.body && err.response.body.errors;
55 | throw err;
56 | }))
57 | .finally(action(() => { this.inProgress = false; }));
58 | }
59 |
60 | @action logout() {
61 | commonStore.setToken(undefined);
62 | userStore.forgetUser();
63 | return Promise.resolve();
64 | }
65 | }
66 |
67 | export default new AuthStore();
68 |
--------------------------------------------------------------------------------
/src/stores/commentsStore.js:
--------------------------------------------------------------------------------
1 | import { observable, action } from 'mobx';
2 | import agent from '../agent';
3 |
4 | export class CommentsStore {
5 |
6 | @observable isCreatingComment = false;
7 | @observable isLoadingComments = false;
8 | @observable commentErrors = undefined;
9 | @observable articleSlug = undefined;
10 | @observable comments = [];
11 |
12 | @action setArticleSlug(articleSlug) {
13 | if (this.articleSlug !== articleSlug) {
14 | this.comments = [];
15 | this.articleSlug = articleSlug;
16 | }
17 | }
18 |
19 | @action loadComments() {
20 | this.isLoadingComments = true;
21 | this.commentErrors = undefined;
22 | return agent.Comments.forArticle(this.articleSlug)
23 | .then(action(({ comments }) => { this.comments = comments; }))
24 | .catch(action(err => {
25 | this.commentErrors = err.response && err.response.body && err.response.body.errors;
26 | throw err;
27 | }))
28 | .finally(action(() => { this.isLoadingComments = false; }));
29 | }
30 |
31 |
32 | @action createComment(comment) {
33 | this.isCreatingComment = true;
34 | return agent.Comments.create(this.articleSlug, comment)
35 | .then(() => this.loadComments())
36 | .finally(action(() => { this.isCreatingComment = false; }));
37 | }
38 |
39 | @action deleteComment(id) {
40 | const idx = this.comments.findIndex(c => c.id === id);
41 | if (idx > -1) this.comments.splice(idx, 1);
42 | return agent.Comments.delete(this.articleSlug, id)
43 | .catch(action(err => { this.loadComments(); throw err }));
44 | }
45 | }
46 |
47 | export default new CommentsStore();
48 |
--------------------------------------------------------------------------------
/src/stores/commonStore.js:
--------------------------------------------------------------------------------
1 | import { observable, action, reaction } from 'mobx';
2 | import agent from '../agent';
3 |
4 | class CommonStore {
5 |
6 | @observable appName = 'Conduit';
7 | @observable token = window.localStorage.getItem('jwt');
8 | @observable appLoaded = false;
9 |
10 | @observable tags = [];
11 | @observable isLoadingTags = false;
12 |
13 | constructor() {
14 | reaction(
15 | () => this.token,
16 | token => {
17 | if (token) {
18 | window.localStorage.setItem('jwt', token);
19 | } else {
20 | window.localStorage.removeItem('jwt');
21 | }
22 | }
23 | );
24 | }
25 |
26 | @action loadTags() {
27 | this.isLoadingTags = true;
28 | return agent.Tags.getAll()
29 | .then(action(({ tags }) => { this.tags = tags.map(t => t.toLowerCase()); }))
30 | .finally(action(() => { this.isLoadingTags = false; }))
31 | }
32 |
33 | @action setToken(token) {
34 | this.token = token;
35 | }
36 |
37 | @action setAppLoaded() {
38 | this.appLoaded = true;
39 | }
40 |
41 | }
42 |
43 | export default new CommonStore();
44 |
--------------------------------------------------------------------------------
/src/stores/editorStore.js:
--------------------------------------------------------------------------------
1 | import { observable, action } from 'mobx';
2 | import articlesStore from './articlesStore';
3 |
4 | class EditorStore {
5 |
6 | @observable inProgress = false;
7 | @observable errors = undefined;
8 | @observable articleSlug = undefined;
9 |
10 | @observable title = '';
11 | @observable description = '';
12 | @observable body = '';
13 | @observable tagList = [];
14 |
15 | @action setArticleSlug(articleSlug) {
16 | if (this.articleSlug !== articleSlug) {
17 | this.reset();
18 | this.articleSlug = articleSlug;
19 | }
20 | }
21 |
22 | @action loadInitialData() {
23 | if (!this.articleSlug) return Promise.resolve();
24 | this.inProgress = true;
25 | return articlesStore.loadArticle(this.articleSlug, { acceptCached: true })
26 | .then(action((article) => {
27 | if (!article) throw new Error('Can\'t load original article');
28 | this.title = article.title;
29 | this.description = article.description;
30 | this.body = article.body;
31 | this.tagList = article.tagList;
32 | }))
33 | .finally(action(() => { this.inProgress = false; }));
34 | }
35 |
36 | @action reset() {
37 | this.title = '';
38 | this.description = '';
39 | this.body = '';
40 | this.tagList = [];
41 | }
42 |
43 | @action setTitle(title) {
44 | this.title = title;
45 | }
46 |
47 | @action setDescription(description) {
48 | this.description = description;
49 | }
50 |
51 | @action setBody(body) {
52 | this.body = body;
53 | }
54 |
55 | @action addTag(tag) {
56 | if (this.tagList.includes(tag)) return;
57 | this.tagList.push(tag);
58 | }
59 |
60 | @action removeTag(tag) {
61 | this.tagList = this.tagList.filter(t => t !== tag);
62 | }
63 |
64 | @action submit() {
65 | this.inProgress = true;
66 | this.errors = undefined;
67 | const article = {
68 | title: this.title,
69 | description: this.description,
70 | body: this.body,
71 | tagList: this.tagList,
72 | slug: this.articleSlug,
73 | };
74 | return (this.articleSlug ? articlesStore.updateArticle(article) : articlesStore.createArticle(article))
75 | .catch(action((err) => {
76 | this.errors = err.response && err.response.body && err.response.body.errors; throw err;
77 | }))
78 | .finally(action(() => { this.inProgress = false; }));
79 | }
80 | }
81 |
82 | export default new EditorStore();
83 |
--------------------------------------------------------------------------------
/src/stores/profileStore.js:
--------------------------------------------------------------------------------
1 | import { observable, action } from 'mobx';
2 | import agent from '../agent';
3 |
4 | class ProfileStore {
5 |
6 | @observable profile = undefined;
7 | @observable isLoadingProfile = false;
8 |
9 | @action loadProfile(username) {
10 | this.isLoadingProfile = true;
11 | agent.Profile.get(username)
12 | .then(action(({ profile }) => { this.profile = profile; }))
13 | .finally(action(() => { this.isLoadingProfile = false; }))
14 | }
15 |
16 | @action follow() {
17 | if (this.profile && !this.profile.following) {
18 | this.profile.following = true;
19 | agent.Profile.follow(this.profile.username)
20 | .catch(action(() => { this.profile.following = false }));
21 | }
22 | }
23 |
24 | @action unfollow() {
25 | if (this.profile && this.profile.following) {
26 | this.profile.following = false;
27 | agent.Profile.unfollow(this.profile.username)
28 | .catch(action(() => { this.profile.following = true }));
29 | }
30 | }
31 | }
32 |
33 | export default new ProfileStore();
34 |
--------------------------------------------------------------------------------
/src/stores/userStore.js:
--------------------------------------------------------------------------------
1 | import { observable, action } from 'mobx';
2 | import agent from '../agent';
3 |
4 | class UserStore {
5 |
6 | @observable currentUser;
7 | @observable loadingUser;
8 | @observable updatingUser;
9 | @observable updatingUserErrors;
10 |
11 | @action pullUser() {
12 | this.loadingUser = true;
13 | return agent.Auth.current()
14 | .then(action(({ user }) => { this.currentUser = user; }))
15 | .finally(action(() => { this.loadingUser = false; }))
16 | }
17 |
18 | @action updateUser(newUser) {
19 | this.updatingUser = true;
20 | return agent.Auth.save(newUser)
21 | .then(action(({ user }) => { this.currentUser = user; }))
22 | .finally(action(() => { this.updatingUser = false; }))
23 | }
24 |
25 | @action forgetUser() {
26 | this.currentUser = undefined;
27 | }
28 |
29 | }
30 |
31 | export default new UserStore();
32 |
--------------------------------------------------------------------------------