├── .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 | # ![React + Mobx Example App](project-logo.png) 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 | [![Brought to you by Thinkster](https://raw.githubusercontent.com/gothinkster/realworld/master/media/end.png)](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 | 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 | 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 | 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 | 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 |
12 |

{comment.body}

13 |
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 |
27 |
28 | 82 | 83 | 84 |
85 | 92 |
93 | 94 |
95 | 102 |
103 | 104 | 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 | 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 | --------------------------------------------------------------------------------