├── .gitignore ├── README.md ├── package-lock.json ├── package.json ├── public ├── favicon.ico └── index.html └── src ├── agent.js ├── components ├── App.js ├── Article │ ├── ArticleActions.js │ ├── ArticleMeta.js │ ├── Comment.js │ ├── CommentContainer.js │ ├── CommentInput.js │ ├── CommentList.js │ ├── DeleteButton.js │ └── index.js ├── ArticleList.js ├── ArticlePreview.js ├── Editor.js ├── Header.js ├── Home │ ├── Banner.js │ ├── MainView.js │ ├── Tags.js │ └── index.js ├── ListErrors.js ├── ListPagination.js ├── Login.js ├── Profile.js ├── ProfileFavorites.js ├── Register.js └── Settings.js ├── constants └── actionTypes.js ├── index.js ├── middleware.js ├── reducer.js ├── reducers ├── article.js ├── articleList.js ├── auth.js ├── common.js ├── editor.js ├── home.js ├── profile.js └── settings.js └── store.js /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | 6 | # testing 7 | coverage 8 | 9 | # production 10 | build 11 | 12 | # misc 13 | .DS_Store 14 | .env 15 | npm-debug.log 16 | .idea 17 | 18 | tags 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Twitter-clone Front-end 2 | 3 | build twitter-like front-end for internet engeering course - fall 99 4 | 5 | with react+redux 6 | 7 | ## team mates 8 | 9 | 1. Roozbeh Sharifnasab [+](github.com/rsharifnasab) 10 | 2. Parsa Fadaee [+](github.com/ParsaFadaei) 11 | 3. Seyed Mohammad Farahani [+](github.com/SeyedMohammadFarahani) 12 | 13 | ## Special thanks to 14 | 15 | - Course instructor: Parham Alvani [+](github.com/1995parham) 16 | - Teaching assistant team 17 | 1. Mohammad Movahed [+](https://github.com/funnyphantom) 18 | 2. Reza Ferdowsi [+](https://github.com/rferdosi) 19 | 3. Parsa Hejabi [+](https://github.com/ParsaHejabi) 20 | 4. Pouya [+]() 21 | 22 | ## Getting started 23 | 24 | To get the frontend running locally: 25 | 26 | - Clone this repo 27 | - install `cross-env` 28 | - `npm install` to install all req'd dependencies 29 | - `npm start` to start the local server (this project uses create-react-app) 30 | 31 | Local web server will use port 4100. You can configure port in scripts section of `package.json`: we use [cross-env](https://github.com/kentcdodds/cross-env) to set environment variable PORT for React scripts, this is Windows-compatible way of setting environment variables. 32 | 33 | 34 | ### Making requests to the backend API 35 | 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. `https://conduit.productionready.io/api`) 36 | 37 | 38 | #### inspired by [real world](https://github.com/gothinkster/react-redux-realworld-example-app) 39 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-redux-realworld-example-app", 3 | "version": "0.1.0", 4 | "private": true, 5 | "devDependencies": { 6 | "cross-env": "^5.1.4", 7 | "react-scripts": "1.1.1" 8 | }, 9 | "dependencies": { 10 | "history": "^4.6.3", 11 | "marked": "^0.3.6", 12 | "prop-types": "^15.5.10", 13 | "react": "^16.3.0", 14 | "react-dom": "^16.3.0", 15 | "react-redux": "^5.0.7", 16 | "react-router": "^4.1.2", 17 | "react-router-dom": "^4.1.2", 18 | "react-router-redux": "^5.0.0-alpha.6", 19 | "redux": "^3.6.0", 20 | "redux-devtools-extension": "^2.13.2", 21 | "redux-logger": "^3.0.1", 22 | "superagent": "^3.8.2", 23 | "superagent-promise": "^1.1.0" 24 | }, 25 | "scripts": { 26 | "start": "cross-env PORT=4100 react-scripts start", 27 | "build": "react-scripts build", 28 | "test": "cross-env PORT=4100 react-scripts test --env=jsdom", 29 | "eject": "react-scripts eject" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rsharifnasab/twitter-frontend/ce02b1a2e2e801fa23171c202489ec0329463e08/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 19 | Twitter 20 | 21 | 22 |
23 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /src/agent.js: -------------------------------------------------------------------------------- 1 | import superagentPromise from 'superagent-promise'; 2 | import _superagent from 'superagent'; 3 | 4 | const superagent = superagentPromise(_superagent, global.Promise); 5 | 6 | //const API_ROOT = 'https://conduit.productionready.io/api'; 7 | const API_ROOT = 'http://localhost:8585/api'; 8 | 9 | const encode = encodeURIComponent; 10 | const responseBody = res => res.body; 11 | 12 | let token = null; 13 | const tokenPlugin = req => { 14 | if (token) { 15 | req.set('authorization', `Token ${token}`); 16 | } 17 | } 18 | 19 | const requests = { 20 | del: url => 21 | superagent.del(`${API_ROOT}${url}`).use(tokenPlugin).then(responseBody), 22 | get: url => 23 | superagent.get(`${API_ROOT}${url}`).use(tokenPlugin).then(responseBody), 24 | put: (url, body) => 25 | superagent.put(`${API_ROOT}${url}`, body).use(tokenPlugin).then(responseBody), 26 | post: (url, body) => 27 | superagent.post(`${API_ROOT}${url}`, body).use(tokenPlugin).then(responseBody) 28 | }; 29 | 30 | const Auth = { 31 | current: () => 32 | requests.get('/user'), 33 | login: (email, password) => 34 | requests.post('/users/login', { user: { email, password } }), 35 | register: (username, email, password) => 36 | requests.post('/users', { user: { username, email, password } }), 37 | save: user => 38 | requests.put('/user', { user }) 39 | }; 40 | 41 | const Tags = { 42 | getAll: () => requests.get('/tags') 43 | }; 44 | 45 | const limit = (count, p) => `limit=${count}&offset=${p ? p * count : 0}`; 46 | const omitSlug = article => Object.assign({}, article, { slug: undefined }) 47 | const Articles = { 48 | all: page => 49 | requests.get(`/articles?${limit(10, page)}`), 50 | byAuthor: (author, page) => 51 | requests.get(`/articles?author=${encode(author)}&${limit(5, page)}`), 52 | byTag: (tag, page) => 53 | requests.get(`/articles?tag=${encode(tag)}&${limit(10, page)}`), 54 | del: slug => 55 | requests.del(`/articles/${slug}`), 56 | favorite: slug => 57 | requests.post(`/articles/${slug}/favorite`), 58 | favoritedBy: (author, page) => 59 | requests.get(`/articles?favorited=${encode(author)}&${limit(5, page)}`), 60 | feed: () => 61 | requests.get('/articles/feed?limit=10&offset=0'), 62 | get: slug => 63 | requests.get(`/articles/${slug}`), 64 | unfavorite: slug => 65 | requests.del(`/articles/${slug}/favorite`), 66 | update: article => 67 | requests.put(`/articles/${article.slug}`, { article: omitSlug(article) }), 68 | create: article => 69 | requests.post('/articles', { article }) 70 | }; 71 | 72 | const Comments = { 73 | create: (slug, comment) => 74 | requests.post(`/articles/${slug}/comments`, { comment }), 75 | delete: (slug, commentId) => 76 | requests.del(`/articles/${slug}/comments/${commentId}`), 77 | forArticle: slug => 78 | requests.get(`/articles/${slug}/comments`) 79 | }; 80 | 81 | const Profile = { 82 | follow: username => 83 | requests.post(`/profiles/${username}/follow`), 84 | get: username => 85 | requests.get(`/profiles/${username}`), 86 | unfollow: username => 87 | requests.del(`/profiles/${username}/follow`) 88 | }; 89 | 90 | export default { 91 | Articles, 92 | Auth, 93 | Comments, 94 | Profile, 95 | Tags, 96 | setToken: _token => { token = _token; } 97 | }; 98 | -------------------------------------------------------------------------------- /src/components/App.js: -------------------------------------------------------------------------------- 1 | import agent from '../agent'; 2 | import Header from './Header'; 3 | import React from 'react'; 4 | import { connect } from 'react-redux'; 5 | import { APP_LOAD, REDIRECT } from '../constants/actionTypes'; 6 | import { Route, Switch } from 'react-router-dom'; 7 | import Article from '../components/Article'; 8 | import Editor from '../components/Editor'; 9 | import Home from '../components/Home'; 10 | import Login from '../components/Login'; 11 | import Profile from '../components/Profile'; 12 | import ProfileFavorites from '../components/ProfileFavorites'; 13 | import Register from '../components/Register'; 14 | import Settings from '../components/Settings'; 15 | import { store } from '../store'; 16 | import { push } from 'react-router-redux'; 17 | 18 | const mapStateToProps = state => { 19 | return { 20 | appLoaded: state.common.appLoaded, 21 | appName: state.common.appName, 22 | currentUser: state.common.currentUser, 23 | redirectTo: state.common.redirectTo 24 | }}; 25 | 26 | const mapDispatchToProps = dispatch => ({ 27 | onLoad: (payload, token) => 28 | dispatch({ type: APP_LOAD, payload, token, skipTracking: true }), 29 | onRedirect: () => 30 | dispatch({ type: REDIRECT }) 31 | }); 32 | 33 | class App extends React.Component { 34 | componentWillReceiveProps(nextProps) { 35 | if (nextProps.redirectTo) { 36 | // this.context.router.replace(nextProps.redirectTo); 37 | store.dispatch(push(nextProps.redirectTo)); 38 | this.props.onRedirect(); 39 | } 40 | } 41 | 42 | componentWillMount() { 43 | const token = window.localStorage.getItem('jwt'); 44 | if (token) { 45 | agent.setToken(token); 46 | } 47 | 48 | this.props.onLoad(token ? agent.Auth.current() : null, token); 49 | } 50 | 51 | render() { 52 | if (this.props.appLoaded) { 53 | return ( 54 |
55 |
58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 |
70 | ); 71 | } 72 | return ( 73 |
74 |
77 |
78 | ); 79 | } 80 | } 81 | 82 | // App.contextTypes = { 83 | // router: PropTypes.object.isRequired 84 | // }; 85 | 86 | export default connect(mapStateToProps, mapDispatchToProps)(App); 87 | -------------------------------------------------------------------------------- /src/components/Article/ArticleActions.js: -------------------------------------------------------------------------------- 1 | //import { Link } from 'react-router-dom'; 2 | import React from 'react'; 3 | import agent from '../../agent'; 4 | import { connect } from 'react-redux'; 5 | import { DELETE_ARTICLE } from '../../constants/actionTypes'; 6 | 7 | const mapDispatchToProps = dispatch => ({ 8 | onClickDelete: payload => 9 | dispatch({ type: DELETE_ARTICLE, payload }) 10 | }); 11 | 12 | const ArticleActions = props => { 13 | const article = props.article; 14 | const del = () => { 15 | props.onClickDelete(agent.Articles.del(article.slug)) 16 | }; 17 | const ret = ()=> { 18 | console.log("retweet") 19 | }; 20 | if (props.canModify) { 21 | return ( 22 | 23 | {/* 24 | 27 | Edit Article 28 | 29 | */} 30 | 31 | 34 | 35 | 36 | ); 37 | } 38 | 39 | return ( 40 | 41 | 44 | 45 | ); 46 | }; 47 | 48 | export default connect(() => ({}), mapDispatchToProps)(ArticleActions); 49 | -------------------------------------------------------------------------------- /src/components/Article/ArticleMeta.js: -------------------------------------------------------------------------------- 1 | import ArticleActions from './ArticleActions'; 2 | import { Link } from 'react-router-dom'; 3 | import React from 'react'; 4 | 5 | const ArticleMeta = props => { 6 | const article = props.article; 7 | return ( 8 |
9 | 10 | {article.author.username} 11 | 12 | 13 |
14 | 15 | {article.author.username} 16 | 17 | 18 | {new Date(article.createdAt).toDateString()} 19 | 20 |
21 | 22 | 23 |
24 | ); 25 | }; 26 | 27 | export default ArticleMeta; 28 | -------------------------------------------------------------------------------- /src/components/Article/Comment.js: -------------------------------------------------------------------------------- 1 | import DeleteButton from './DeleteButton'; 2 | import { Link } from 'react-router-dom'; 3 | import React from 'react'; 4 | 5 | const Comment = props => { 6 | const comment = props.comment; 7 | const show = props.currentUser && 8 | props.currentUser.username === comment.author.username; 9 | return ( 10 |
11 |
12 |

{comment.body}

13 |
14 |
15 | 18 | {comment.author.username} 19 | 20 |   21 | 24 | {comment.author.username} 25 | 26 | 27 | {new Date(comment.createdAt).toDateString()} 28 | 29 | 30 |
31 |
32 | ); 33 | }; 34 | 35 | export default Comment; 36 | -------------------------------------------------------------------------------- /src/components/Article/CommentContainer.js: -------------------------------------------------------------------------------- 1 | import CommentInput from './CommentInput'; 2 | import CommentList from './CommentList'; 3 | import { Link } from 'react-router-dom'; 4 | import React from 'react'; 5 | 6 | const CommentContainer = props => { 7 | if (props.currentUser) { 8 | return ( 9 |
10 |
11 | 12 | 13 |
14 | 15 | 19 |
20 | ); 21 | } else { 22 | return ( 23 |
24 |

25 | Sign in 26 |  or  27 | sign up 28 |  to add comments on this article. 29 |

30 | 31 | 35 |
36 | ); 37 | } 38 | }; 39 | 40 | export default CommentContainer; 41 | -------------------------------------------------------------------------------- /src/components/Article/CommentInput.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import agent from '../../agent'; 3 | import { connect } from 'react-redux'; 4 | import { ADD_COMMENT } from '../../constants/actionTypes'; 5 | 6 | const mapDispatchToProps = dispatch => ({ 7 | onSubmit: payload => 8 | dispatch({ type: ADD_COMMENT, payload }) 9 | }); 10 | 11 | class CommentInput extends React.Component { 12 | constructor() { 13 | super(); 14 | this.state = { 15 | body: '' 16 | }; 17 | 18 | this.setBody = ev => { 19 | this.setState({ body: ev.target.value }); 20 | }; 21 | 22 | this.createComment = ev => { 23 | ev.preventDefault(); 24 | const payload = agent.Comments.create(this.props.slug, 25 | { body: this.state.body }); 26 | this.setState({ body: '' }); 27 | this.props.onSubmit(payload); 28 | }; 29 | } 30 | 31 | render() { 32 | return ( 33 |
34 |
35 | 41 |
42 |
43 | {this.props.currentUser.username} 47 | 52 |
53 |
54 | ); 55 | } 56 | } 57 | 58 | export default connect(() => ({}), mapDispatchToProps)(CommentInput); 59 | -------------------------------------------------------------------------------- /src/components/Article/CommentList.js: -------------------------------------------------------------------------------- 1 | import Comment from './Comment'; 2 | import React from 'react'; 3 | 4 | const CommentList = props => { 5 | return ( 6 |
7 | { 8 | props.comments.map(comment => { 9 | return ( 10 | 15 | ); 16 | }) 17 | } 18 |
19 | ); 20 | }; 21 | 22 | export default CommentList; 23 | -------------------------------------------------------------------------------- /src/components/Article/DeleteButton.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import agent from '../../agent'; 3 | import { connect } from 'react-redux'; 4 | import { DELETE_COMMENT } from '../../constants/actionTypes'; 5 | 6 | const mapDispatchToProps = dispatch => ({ 7 | onClick: (payload, commentId) => 8 | dispatch({ type: DELETE_COMMENT, payload, commentId }) 9 | }); 10 | 11 | const DeleteButton = props => { 12 | const del = () => { 13 | const payload = agent.Comments.delete(props.slug, props.commentId); 14 | props.onClick(payload, props.commentId); 15 | }; 16 | 17 | if (props.show) { 18 | return ( 19 | 20 | 21 | 22 | ); 23 | } 24 | return null; 25 | }; 26 | 27 | export default connect(() => ({}), mapDispatchToProps)(DeleteButton); 28 | -------------------------------------------------------------------------------- /src/components/Article/index.js: -------------------------------------------------------------------------------- 1 | import ArticleMeta from './ArticleMeta'; 2 | import CommentContainer from './CommentContainer'; 3 | import React from 'react'; 4 | import agent from '../../agent'; 5 | import { 6 | connect 7 | } from 'react-redux'; 8 | //import marked from 'marked'; 9 | import { 10 | ARTICLE_PAGE_LOADED, 11 | ARTICLE_PAGE_UNLOADED 12 | } from '../../constants/actionTypes'; 13 | 14 | const mapStateToProps = state => ({ 15 | ...state.article, 16 | currentUser: state.common.currentUser 17 | }); 18 | 19 | const mapDispatchToProps = dispatch => ({ 20 | onLoad: payload => 21 | dispatch({ 22 | type: ARTICLE_PAGE_LOADED, 23 | payload 24 | }), 25 | onUnload: () => 26 | dispatch({ 27 | type: ARTICLE_PAGE_UNLOADED 28 | }) 29 | }); 30 | 31 | class Article extends React.Component { 32 | componentWillMount() { 33 | this.props.onLoad(Promise.all([ 34 | agent.Articles.get(this.props.match.params.id), 35 | agent.Comments.forArticle(this.props.match.params.id) 36 | ])); 37 | } 38 | 39 | componentWillUnmount() { 40 | this.props.onUnload(); 41 | } 42 | 43 | render() { 44 | if (!this.props.article) { 45 | return null; 46 | } 47 | 48 | /* 49 | const markup = { 50 | __html: marked(this.props.article.body, { 51 | sanitize: true 52 | }) 53 | }; 54 | */ 55 | const canModify = this.props.currentUser && 56 | this.props.currentUser.username === this.props.article.author.username; 57 | return ( 58 |
59 | 60 |
61 |
62 | 63 |

{this.props.article.title}

64 | 67 | 68 |
69 |
70 | 71 |
72 | 73 |
74 |
75 | 76 | {/*
*/} 77 |
78 |
 79 |                                     {this.props.article.body}
 80 |                                 
81 |
82 | 83 | 84 |
    85 | { 86 | this.props.article.tagList.map(tag => { 87 | return ( 88 |
  • 91 | {tag} 92 |
  • 93 | ); 94 | }) 95 | } 96 |
97 | 98 |
99 |
100 | 101 |
102 | 103 |
104 |
105 | 106 |
107 | 112 |
113 |
114 |
115 | ); 116 | } 117 | } 118 | 119 | export default connect(mapStateToProps, mapDispatchToProps)(Article); 120 | -------------------------------------------------------------------------------- /src/components/ArticleList.js: -------------------------------------------------------------------------------- 1 | import ArticlePreview from './ArticlePreview'; 2 | import ListPagination from './ListPagination'; 3 | import React from 'react'; 4 | 5 | const ArticleList = props => { 6 | if (!props.articles) { 7 | return ( 8 |
Loading...
9 | ); 10 | } 11 | 12 | if (props.articles.length === 0) { 13 | return ( 14 |
15 | No Tweet is here yet. 16 |
17 | ); 18 | } 19 | 20 | return ( 21 |
22 | { 23 | props.articles.map(article => { 24 | return ( 25 | 26 | ); 27 | }) 28 | } 29 | 30 | 34 |
35 | ); 36 | }; 37 | 38 | export default ArticleList; 39 | -------------------------------------------------------------------------------- /src/components/ArticlePreview.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import agent from '../agent'; 4 | import { connect } from 'react-redux'; 5 | import { ARTICLE_FAVORITED, ARTICLE_UNFAVORITED } from '../constants/actionTypes'; 6 | 7 | const FAVORITED_CLASS = 'btn btn-sm btn-primary'; 8 | const NOT_FAVORITED_CLASS = 'btn btn-sm btn-outline-primary'; 9 | 10 | const mapDispatchToProps = dispatch => ({ 11 | favorite: slug => dispatch({ 12 | type: ARTICLE_FAVORITED, 13 | payload: agent.Articles.favorite(slug) 14 | }), 15 | unfavorite: slug => dispatch({ 16 | type: ARTICLE_UNFAVORITED, 17 | payload: agent.Articles.unfavorite(slug) 18 | }) 19 | }); 20 | 21 | const ArticlePreview = props => { 22 | const article = props.article; 23 | const favoriteButtonClass = article.favorited ? 24 | FAVORITED_CLASS : 25 | NOT_FAVORITED_CLASS; 26 | 27 | const handleLike = ev => { 28 | ev.preventDefault(); 29 | if (article.favorited) { 30 | props.unfavorite(article.slug); 31 | } else { 32 | props.favorite(article.slug); 33 | } 34 | }; 35 | 36 | const handleRet = ev => { 37 | ev.preventDefault(); 38 | } 39 | 40 | 41 | 42 | return ( 43 |
44 |
45 | 46 | {article.author.username} 47 | 48 | 49 |
50 | 51 | {article.author.username} 52 | 53 | 54 | {new Date(article.createdAt).toDateString()} 55 | 56 |
57 | 58 |
59 | 62 |
63 |
64 | 67 |
68 |
69 | 70 | 71 |

{article.title}

72 | {/*

{article.description}

*/} 73 |
{article.body}
74 | {/*Read more... */} 75 | 86 | 87 |
88 | ); 89 | } 90 | 91 | export default connect(() => ({}), mapDispatchToProps)(ArticlePreview); 92 | -------------------------------------------------------------------------------- /src/components/Editor.js: -------------------------------------------------------------------------------- 1 | import ListErrors from './ListErrors'; 2 | import React from 'react'; 3 | import agent from '../agent'; 4 | import { 5 | connect 6 | } from 'react-redux'; 7 | import { 8 | ADD_TAG, 9 | EDITOR_PAGE_LOADED, 10 | REMOVE_TAG, 11 | ARTICLE_SUBMITTED, 12 | EDITOR_PAGE_UNLOADED, 13 | UPDATE_FIELD_EDITOR 14 | } from '../constants/actionTypes'; 15 | 16 | const mapStateToProps = state => ({ 17 | ...state.editor 18 | }); 19 | 20 | const mapDispatchToProps = dispatch => ({ 21 | onAddTag: () => 22 | dispatch({ 23 | type: ADD_TAG 24 | }), 25 | onLoad: payload => 26 | dispatch({ 27 | type: EDITOR_PAGE_LOADED, 28 | payload 29 | }), 30 | onRemoveTag: tag => 31 | dispatch({ 32 | type: REMOVE_TAG, 33 | tag 34 | }), 35 | onSubmit: payload => 36 | dispatch({ 37 | type: ARTICLE_SUBMITTED, 38 | payload 39 | }), 40 | onUnload: payload => 41 | dispatch({ 42 | type: EDITOR_PAGE_UNLOADED 43 | }), 44 | onUpdateField: (key, value) => 45 | dispatch({ 46 | type: UPDATE_FIELD_EDITOR, 47 | key, 48 | value 49 | }) 50 | }); 51 | 52 | class Editor extends React.Component { 53 | constructor() { 54 | super(); 55 | 56 | const updateFieldEvent = 57 | key => ev => this.props.onUpdateField(key, ev.target.value); 58 | this.changeTitle = updateFieldEvent('title'); 59 | this.changeDescription = updateFieldEvent('description'); 60 | this.changeBody = updateFieldEvent('body'); 61 | this.changeTagInput = updateFieldEvent('tagInput'); 62 | 63 | this.watchForEnter = ev => { 64 | if (ev.keyCode === 13) { 65 | ev.preventDefault(); 66 | this.props.onAddTag(); 67 | } 68 | }; 69 | 70 | this.removeTagHandler = tag => () => { 71 | this.props.onRemoveTag(tag); 72 | }; 73 | 74 | this.findTags = (body) => { 75 | const regex = /(?:^|\s)(?:#)([a-zA-Z\d]+)/gm; 76 | const matches = []; 77 | let match; 78 | 79 | while ((match = regex.exec(body))) { 80 | matches.push(match[1]); 81 | } 82 | return matches; 83 | } 84 | 85 | this.submitForm = ev => { 86 | ev.preventDefault(); 87 | 88 | if(!this.props.title || !this.props.body || this.props.body.length > 250){ 89 | return; 90 | } 91 | //const tags = this.props.tagList; 92 | const tags = this.findTags(this.props.body); 93 | 94 | const article = { 95 | title: this.props.title, 96 | description: ""+new Date(), 97 | body: this.props.body, 98 | tagList: tags 99 | }; 100 | 101 | const slug = { 102 | slug: this.props.articleSlug 103 | }; 104 | const promise = this.props.articleSlug ? 105 | agent.Articles.update(Object.assign(article, slug)) : 106 | agent.Articles.create(article); 107 | 108 | this.props.onSubmit(promise); 109 | }; 110 | } 111 | 112 | componentWillReceiveProps(nextProps) { 113 | if (this.props.match.params.slug !== nextProps.match.params.slug) { 114 | if (nextProps.match.params.slug) { 115 | this.props.onUnload(); 116 | return this.props.onLoad(agent.Articles.get(this.props.match.params.slug)); 117 | } 118 | this.props.onLoad(null); 119 | } 120 | } 121 | 122 | componentWillMount() { 123 | if (this.props.match.params.slug) { 124 | return this.props.onLoad(agent.Articles.get(this.props.match.params.slug)); 125 | } 126 | this.props.onLoad(null); 127 | } 128 | 129 | componentWillUnmount() { 130 | this.props.onUnload(); 131 | } 132 | 133 | render() { 134 | return ( 135 |
136 |
137 |
138 |
139 | 140 | 141 | 142 |
143 |
144 | 145 |
146 | 152 |
153 | {/* 154 |
155 | 161 |
162 | */} 163 | 164 |
165 | 172 |
173 | 174 | {/* 175 |
176 | 183 | 184 |
185 | { 186 | (this.props.tagList || []).map(tag => { 187 | return ( 188 | 189 | 191 | 192 | {tag} 193 | 194 | ); 195 | }) 196 | } 197 |
198 |
199 | */} 200 | 201 | 208 | 209 |
210 |
211 | 212 |
213 |
214 |
215 |
216 | ); 217 | } 218 | } 219 | 220 | export default connect(mapStateToProps, mapDispatchToProps)(Editor); 221 | -------------------------------------------------------------------------------- /src/components/Header.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | 4 | const LoggedOutView = props => { 5 | if (!props.currentUser) { 6 | return ( 7 | 28 | ); 29 | } 30 | return null; 31 | }; 32 | 33 | const LoggedInView = props => { 34 | if (props.currentUser) { 35 | return ( 36 | 66 | ); 67 | } 68 | 69 | return null; 70 | }; 71 | 72 | class Header extends React.Component { 73 | render() { 74 | return ( 75 | 87 | ); 88 | } 89 | } 90 | 91 | export default Header; 92 | -------------------------------------------------------------------------------- /src/components/Home/Banner.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Banner = ({ appName, token }) => { 4 | if (false && token) { 5 | return null; 6 | } 7 | return ( 8 |
9 |
10 |

11 | {appName.toLowerCase()} 12 |

13 |

awesome twitter clone for IE project

14 |
15 |
16 | ); 17 | }; 18 | 19 | export default Banner; 20 | -------------------------------------------------------------------------------- /src/components/Home/MainView.js: -------------------------------------------------------------------------------- 1 | import ArticleList from '../ArticleList'; 2 | import React from 'react'; 3 | import agent from '../../agent'; 4 | import { connect } from 'react-redux'; 5 | import { CHANGE_TAB } from '../../constants/actionTypes'; 6 | 7 | const YourFeedTab = props => { 8 | if (props.token) { 9 | const clickHandler = ev => { 10 | ev.preventDefault(); 11 | props.onTabClick('feed', agent.Articles.feed, agent.Articles.feed()); 12 | } 13 | 14 | return ( 15 |
  • 16 | 19 | Your Feed 20 | 21 |
  • 22 | ); 23 | } 24 | return null; 25 | }; 26 | 27 | const GlobalFeedTab = props => { 28 | const clickHandler = ev => { 29 | ev.preventDefault(); 30 | props.onTabClick('all', agent.Articles.all, agent.Articles.all()); 31 | }; 32 | return ( 33 |
  • 34 | 38 | Global Feed 39 | 40 |
  • 41 | ); 42 | }; 43 | 44 | const TagFilterTab = props => { 45 | if (!props.tag) { 46 | return null; 47 | } 48 | 49 | return ( 50 |
  • 51 | 52 | {props.tag} 53 | 54 |
  • 55 | ); 56 | }; 57 | 58 | const mapStateToProps = state => ({ 59 | ...state.articleList, 60 | tags: state.home.tags, 61 | token: state.common.token 62 | }); 63 | 64 | const mapDispatchToProps = dispatch => ({ 65 | onTabClick: (tab, pager, payload) => dispatch({ type: CHANGE_TAB, tab, pager, payload }) 66 | }); 67 | 68 | const MainView = props => { 69 | return ( 70 |
    71 |
    72 | 84 |
    85 | 86 | 92 |
    93 | ); 94 | }; 95 | 96 | export default connect(mapStateToProps, mapDispatchToProps)(MainView); 97 | -------------------------------------------------------------------------------- /src/components/Home/Tags.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import agent from '../../agent'; 3 | 4 | const Tags = props => { 5 | const tags = props.tags; 6 | if (tags) { 7 | return ( 8 |
    9 | { 10 | tags.map(tag => { 11 | const handleClick = ev => { 12 | ev.preventDefault(); 13 | props.onClickTag(tag, page => agent.Articles.byTag(tag, page), agent.Articles.byTag(tag)); 14 | }; 15 | 16 | return ( 17 | 22 | {tag} 23 | 24 | ); 25 | }) 26 | } 27 |
    28 | ); 29 | } else { 30 | return ( 31 |
    Loading Tags...
    32 | ); 33 | } 34 | }; 35 | 36 | export default Tags; 37 | -------------------------------------------------------------------------------- /src/components/Home/index.js: -------------------------------------------------------------------------------- 1 | import Banner from './Banner'; 2 | import MainView from './MainView'; 3 | import React from 'react'; 4 | import Tags from './Tags'; 5 | import agent from '../../agent'; 6 | import { connect } from 'react-redux'; 7 | import { 8 | HOME_PAGE_LOADED, 9 | HOME_PAGE_UNLOADED, 10 | APPLY_TAG_FILTER 11 | } from '../../constants/actionTypes'; 12 | 13 | const Promise = global.Promise; 14 | 15 | const mapStateToProps = state => ({ 16 | ...state.home, 17 | appName: state.common.appName, 18 | token: state.common.token 19 | }); 20 | 21 | const mapDispatchToProps = dispatch => ({ 22 | onClickTag: (tag, pager, payload) => 23 | dispatch({ type: APPLY_TAG_FILTER, tag, pager, payload }), 24 | onLoad: (tab, pager, payload) => 25 | dispatch({ type: HOME_PAGE_LOADED, tab, pager, payload }), 26 | onUnload: () => 27 | dispatch({ type: HOME_PAGE_UNLOADED }) 28 | }); 29 | 30 | class Home extends React.Component { 31 | componentWillMount() { 32 | const tab = this.props.token ? 'feed' : 'all'; 33 | const articlesPromise = this.props.token ? 34 | agent.Articles.feed : 35 | agent.Articles.all; 36 | 37 | this.props.onLoad(tab, articlesPromise, Promise.all([agent.Tags.getAll(), articlesPromise()])); 38 | } 39 | 40 | componentWillUnmount() { 41 | this.props.onUnload(); 42 | } 43 | 44 | render() { 45 | return ( 46 |
    47 | 48 | 49 | 50 |
    51 |
    52 | 53 | 54 |
    55 |
    56 | 57 |

    Popular Tags

    58 | 59 | 62 | 63 |
    64 |
    65 |
    66 |
    67 | 68 |
    69 | ); 70 | } 71 | } 72 | 73 | export default connect(mapStateToProps, mapDispatchToProps)(Home); 74 | -------------------------------------------------------------------------------- /src/components/ListErrors.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | class ListErrors extends React.Component { 4 | render() { 5 | const errors = this.props.errors; 6 | if (errors) { 7 | return ( 8 | 19 | ); 20 | } else { 21 | return null; 22 | } 23 | } 24 | } 25 | 26 | export default ListErrors; 27 | -------------------------------------------------------------------------------- /src/components/ListPagination.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import agent from '../agent'; 3 | import { connect } from 'react-redux'; 4 | import { SET_PAGE } from '../constants/actionTypes'; 5 | 6 | const mapDispatchToProps = dispatch => ({ 7 | onSetPage: (page, payload) => 8 | dispatch({ type: SET_PAGE, page, payload }) 9 | }); 10 | 11 | const ListPagination = props => { 12 | if (props.articlesCount <= 10) { 13 | return null; 14 | } 15 | 16 | const range = []; 17 | for (let i = 0; i < Math.ceil(props.articlesCount / 10); ++i) { 18 | range.push(i); 19 | } 20 | 21 | const setPage = page => { 22 | if(props.pager) { 23 | props.onSetPage(page, props.pager(page)); 24 | }else { 25 | props.onSetPage(page, agent.Articles.all(page)) 26 | } 27 | }; 28 | 29 | return ( 30 | 55 | ); 56 | }; 57 | 58 | export default connect(() => ({}), mapDispatchToProps)(ListPagination); 59 | -------------------------------------------------------------------------------- /src/components/Login.js: -------------------------------------------------------------------------------- 1 | import { Link } from 'react-router-dom'; 2 | import ListErrors from './ListErrors'; 3 | import React from 'react'; 4 | import agent from '../agent'; 5 | import { connect } from 'react-redux'; 6 | import { 7 | UPDATE_FIELD_AUTH, 8 | LOGIN, 9 | LOGIN_PAGE_UNLOADED 10 | } from '../constants/actionTypes'; 11 | 12 | const mapStateToProps = state => ({ ...state.auth }); 13 | 14 | const mapDispatchToProps = dispatch => ({ 15 | onChangeEmail: value => 16 | dispatch({ type: UPDATE_FIELD_AUTH, key: 'email', value }), 17 | onChangePassword: value => 18 | dispatch({ type: UPDATE_FIELD_AUTH, key: 'password', value }), 19 | onSubmit: (email, password) => 20 | dispatch({ type: LOGIN, payload: agent.Auth.login(email, password) }), 21 | onUnload: () => 22 | dispatch({ type: LOGIN_PAGE_UNLOADED }) 23 | }); 24 | 25 | class Login extends React.Component { 26 | constructor() { 27 | super(); 28 | this.changeEmail = ev => this.props.onChangeEmail(ev.target.value); 29 | this.changePassword = ev => this.props.onChangePassword(ev.target.value); 30 | this.submitForm = (email, password) => ev => { 31 | ev.preventDefault(); 32 | this.props.onSubmit(email, password); 33 | }; 34 | } 35 | 36 | componentWillUnmount() { 37 | this.props.onUnload(); 38 | } 39 | 40 | render() { 41 | const email = this.props.email; 42 | const password = this.props.password; 43 | return ( 44 |
    45 |
    46 |
    47 | 48 |
    49 |

    Sign In

    50 |

    51 | 52 | Need an account? 53 | 54 |

    55 | 56 | 57 | 58 |
    59 |
    60 | 61 |
    62 | 68 |
    69 | 70 |
    71 | 77 |
    78 | 79 | 85 | 86 |
    87 |
    88 |
    89 | 90 |
    91 |
    92 |
    93 | ); 94 | } 95 | } 96 | 97 | export default connect(mapStateToProps, mapDispatchToProps)(Login); 98 | -------------------------------------------------------------------------------- /src/components/Profile.js: -------------------------------------------------------------------------------- 1 | import ArticleList from './ArticleList'; 2 | import React from 'react'; 3 | import { Link } from 'react-router-dom'; 4 | import agent from '../agent'; 5 | import { connect } from 'react-redux'; 6 | import { 7 | FOLLOW_USER, 8 | UNFOLLOW_USER, 9 | PROFILE_PAGE_LOADED, 10 | PROFILE_PAGE_UNLOADED 11 | } from '../constants/actionTypes'; 12 | 13 | const EditProfileSettings = props => { 14 | if (props.isUser) { 15 | return ( 16 | 19 | Edit Profile Settings 20 | 21 | ); 22 | } 23 | return null; 24 | }; 25 | 26 | const FollowUserButton = props => { 27 | if (props.isUser) { 28 | return null; 29 | } 30 | 31 | let classes = 'btn btn-sm action-btn'; 32 | if (props.user.following) { 33 | classes += ' btn-secondary'; 34 | } else { 35 | classes += ' btn-outline-secondary'; 36 | } 37 | 38 | const handleClick = ev => { 39 | ev.preventDefault(); 40 | if (props.user.following) { 41 | props.unfollow(props.user.username) 42 | } else { 43 | props.follow(props.user.username) 44 | } 45 | }; 46 | 47 | return ( 48 | 55 | ); 56 | }; 57 | 58 | const mapStateToProps = state => ({ 59 | ...state.articleList, 60 | currentUser: state.common.currentUser, 61 | profile: state.profile 62 | }); 63 | 64 | const mapDispatchToProps = dispatch => ({ 65 | onFollow: username => dispatch({ 66 | type: FOLLOW_USER, 67 | payload: agent.Profile.follow(username) 68 | }), 69 | onLoad: payload => dispatch({ type: PROFILE_PAGE_LOADED, payload }), 70 | onUnfollow: username => dispatch({ 71 | type: UNFOLLOW_USER, 72 | payload: agent.Profile.unfollow(username) 73 | }), 74 | onUnload: () => dispatch({ type: PROFILE_PAGE_UNLOADED }) 75 | }); 76 | 77 | class Profile extends React.Component { 78 | componentWillMount() { 79 | this.props.onLoad(Promise.all([ 80 | agent.Profile.get(this.props.match.params.username), 81 | agent.Articles.byAuthor(this.props.match.params.username) 82 | ])); 83 | } 84 | 85 | componentWillUnmount() { 86 | this.props.onUnload(); 87 | } 88 | 89 | renderTabs() { 90 | return ( 91 | 108 | ); 109 | } 110 | 111 | render() { 112 | const profile = this.props.profile; 113 | if (!profile) { 114 | return null; 115 | } 116 | 117 | const isUser = this.props.currentUser && 118 | this.props.profile.username === this.props.currentUser.username; 119 | 120 | return ( 121 |
    122 | 123 |
    124 |
    125 |
    126 |
    127 | 128 | {profile.username} 129 |

    {profile.username}

    130 |

    {profile.bio}

    131 | 132 | 133 | 139 | 140 |
    141 |
    142 |
    143 |
    144 | 145 |
    146 |
    147 | 148 |
    149 | 150 |
    151 | {this.renderTabs()} 152 |
    153 | 154 | 159 |
    160 | 161 |
    162 |
    163 | 164 |
    165 | ); 166 | } 167 | } 168 | 169 | export default connect(mapStateToProps, mapDispatchToProps)(Profile); 170 | export { Profile, mapStateToProps }; 171 | -------------------------------------------------------------------------------- /src/components/ProfileFavorites.js: -------------------------------------------------------------------------------- 1 | import { Profile, mapStateToProps } from './Profile'; 2 | import React from 'react'; 3 | import { Link } from 'react-router-dom'; 4 | import agent from '../agent'; 5 | import { connect } from 'react-redux'; 6 | import { 7 | PROFILE_PAGE_LOADED, 8 | PROFILE_PAGE_UNLOADED 9 | } from '../constants/actionTypes'; 10 | 11 | const mapDispatchToProps = dispatch => ({ 12 | onLoad: (pager, payload) => 13 | dispatch({ type: PROFILE_PAGE_LOADED, pager, payload }), 14 | onUnload: () => 15 | dispatch({ type: PROFILE_PAGE_UNLOADED }) 16 | }); 17 | 18 | class ProfileFavorites extends Profile { 19 | componentWillMount() { 20 | this.props.onLoad(page => agent.Articles.favoritedBy(this.props.match.params.username, page), Promise.all([ 21 | agent.Profile.get(this.props.match.params.username), 22 | agent.Articles.favoritedBy(this.props.match.params.username) 23 | ])); 24 | } 25 | 26 | componentWillUnmount() { 27 | this.props.onUnload(); 28 | } 29 | 30 | renderTabs() { 31 | return ( 32 | 49 | ); 50 | } 51 | } 52 | 53 | export default connect(mapStateToProps, mapDispatchToProps)(ProfileFavorites); 54 | -------------------------------------------------------------------------------- /src/components/Register.js: -------------------------------------------------------------------------------- 1 | import { Link } from 'react-router-dom'; 2 | import ListErrors from './ListErrors'; 3 | import React from 'react'; 4 | import agent from '../agent'; 5 | import { connect } from 'react-redux'; 6 | import { 7 | UPDATE_FIELD_AUTH, 8 | REGISTER, 9 | REGISTER_PAGE_UNLOADED 10 | } from '../constants/actionTypes'; 11 | 12 | const mapStateToProps = state => ({ ...state.auth }); 13 | 14 | const mapDispatchToProps = dispatch => ({ 15 | onChangeEmail: value => 16 | dispatch({ type: UPDATE_FIELD_AUTH, key: 'email', value }), 17 | onChangePassword: value => 18 | dispatch({ type: UPDATE_FIELD_AUTH, key: 'password', value }), 19 | onChangeUsername: value => 20 | dispatch({ type: UPDATE_FIELD_AUTH, key: 'username', value }), 21 | onSubmit: (username, email, password) => { 22 | const payload = agent.Auth.register(username, email, password); 23 | dispatch({ type: REGISTER, payload }) 24 | }, 25 | onUnload: () => 26 | dispatch({ type: REGISTER_PAGE_UNLOADED }) 27 | }); 28 | 29 | class Register extends React.Component { 30 | constructor() { 31 | super(); 32 | this.changeEmail = ev => this.props.onChangeEmail(ev.target.value); 33 | this.changePassword = ev => this.props.onChangePassword(ev.target.value); 34 | this.changeUsername = ev => this.props.onChangeUsername(ev.target.value); 35 | this.submitForm = (username, email, password) => ev => { 36 | ev.preventDefault(); 37 | this.props.onSubmit(username, email, password); 38 | } 39 | } 40 | 41 | componentWillUnmount() { 42 | this.props.onUnload(); 43 | } 44 | 45 | render() { 46 | const email = this.props.email; 47 | const password = this.props.password; 48 | const username = this.props.username; 49 | 50 | return ( 51 |
    52 |
    53 |
    54 | 55 |
    56 |

    Sign Up

    57 |

    58 | 59 | Have an account? 60 | 61 |

    62 | 63 | 64 | 65 |
    66 |
    67 | 68 |
    69 | 75 |
    76 | 77 |
    78 | 84 |
    85 | 86 |
    87 | 93 |
    94 | 95 | 101 | 102 |
    103 |
    104 |
    105 | 106 |
    107 |
    108 |
    109 | ); 110 | } 111 | } 112 | 113 | export default connect(mapStateToProps, mapDispatchToProps)(Register); 114 | -------------------------------------------------------------------------------- /src/components/Settings.js: -------------------------------------------------------------------------------- 1 | import ListErrors from './ListErrors'; 2 | import React from 'react'; 3 | import agent from '../agent'; 4 | import { connect } from 'react-redux'; 5 | import { 6 | SETTINGS_SAVED, 7 | SETTINGS_PAGE_UNLOADED, 8 | LOGOUT 9 | } from '../constants/actionTypes'; 10 | 11 | class SettingsForm extends React.Component { 12 | constructor() { 13 | super(); 14 | 15 | this.state = { 16 | image: '', 17 | username: '', 18 | bio: '', 19 | email: '', 20 | password: '' 21 | }; 22 | 23 | this.updateState = field => ev => { 24 | const state = this.state; 25 | const newState = Object.assign({}, state, { [field]: ev.target.value }); 26 | this.setState(newState); 27 | }; 28 | 29 | this.submitForm = ev => { 30 | ev.preventDefault(); 31 | 32 | const user = Object.assign({}, this.state); 33 | if (!user.password) { 34 | delete user.password; 35 | } 36 | 37 | this.props.onSubmitForm(user); 38 | }; 39 | } 40 | 41 | componentWillMount() { 42 | if (this.props.currentUser) { 43 | Object.assign(this.state, { 44 | image: this.props.currentUser.image || '', 45 | username: this.props.currentUser.username, 46 | bio: this.props.currentUser.bio, 47 | email: this.props.currentUser.email 48 | }); 49 | } 50 | } 51 | 52 | componentWillReceiveProps(nextProps) { 53 | if (nextProps.currentUser) { 54 | this.setState(Object.assign({}, this.state, { 55 | image: nextProps.currentUser.image || '', 56 | username: nextProps.currentUser.username, 57 | bio: nextProps.currentUser.bio, 58 | email: nextProps.currentUser.email 59 | })); 60 | } 61 | } 62 | 63 | render() { 64 | return ( 65 |
    66 |
    67 | 68 |
    69 | 75 |
    76 | 77 |
    78 | 84 |
    85 | 86 |
    87 | 94 |
    95 | 96 |
    97 | 103 |
    104 | 105 |
    106 | 112 |
    113 | 114 | 120 | 121 |
    122 |
    123 | ); 124 | } 125 | } 126 | 127 | const mapStateToProps = state => ({ 128 | ...state.settings, 129 | currentUser: state.common.currentUser 130 | }); 131 | 132 | const mapDispatchToProps = dispatch => ({ 133 | onClickLogout: () => dispatch({ type: LOGOUT }), 134 | onSubmitForm: user => 135 | dispatch({ type: SETTINGS_SAVED, payload: agent.Auth.save(user) }), 136 | onUnload: () => dispatch({ type: SETTINGS_PAGE_UNLOADED }) 137 | }); 138 | 139 | class Settings extends React.Component { 140 | render() { 141 | return ( 142 |
    143 |
    144 |
    145 |
    146 | 147 |

    Your Settings

    148 | 149 | 150 | 151 | 154 | 155 |
    156 | 157 | 162 | 163 |
    164 |
    165 |
    166 |
    167 | ); 168 | } 169 | } 170 | 171 | export default connect(mapStateToProps, mapDispatchToProps)(Settings); 172 | -------------------------------------------------------------------------------- /src/constants/actionTypes.js: -------------------------------------------------------------------------------- 1 | export const APP_LOAD = 'APP_LOAD'; 2 | export const REDIRECT = 'REDIRECT'; 3 | export const ARTICLE_SUBMITTED = 'ARTICLE_SUBMITTED'; 4 | export const SETTINGS_SAVED = 'SETTINGS_SAVED'; 5 | export const DELETE_ARTICLE = 'DELETE_ARTICLE'; 6 | export const SETTINGS_PAGE_UNLOADED = 'SETTINGS_PAGE_UNLOADED'; 7 | export const HOME_PAGE_LOADED = 'HOME_PAGE_LOADED'; 8 | export const HOME_PAGE_UNLOADED = 'HOME_PAGE_UNLOADED'; 9 | export const ARTICLE_PAGE_LOADED = 'ARTICLE_PAGE_LOADED'; 10 | export const ARTICLE_PAGE_UNLOADED = 'ARTICLE_PAGE_UNLOADED'; 11 | export const ADD_COMMENT = 'ADD_COMMENT'; 12 | export const DELETE_COMMENT = 'DELETE_COMMENT'; 13 | export const ARTICLE_FAVORITED = 'ARTICLE_FAVORITED'; 14 | export const ARTICLE_UNFAVORITED = 'ARTICLE_UNFAVORITED'; 15 | export const SET_PAGE = 'SET_PAGE'; 16 | export const APPLY_TAG_FILTER = 'APPLY_TAG_FILTER'; 17 | export const CHANGE_TAB = 'CHANGE_TAB'; 18 | export const PROFILE_PAGE_LOADED = 'PROFILE_PAGE_LOADED'; 19 | export const PROFILE_PAGE_UNLOADED = 'PROFILE_PAGE_UNLOADED'; 20 | export const LOGIN = 'LOGIN'; 21 | export const LOGOUT = 'LOGOUT'; 22 | export const REGISTER = 'REGISTER'; 23 | export const LOGIN_PAGE_UNLOADED = 'LOGIN_PAGE_UNLOADED'; 24 | export const REGISTER_PAGE_UNLOADED = 'REGISTER_PAGE_UNLOADED'; 25 | export const ASYNC_START = 'ASYNC_START'; 26 | export const ASYNC_END = 'ASYNC_END'; 27 | export const EDITOR_PAGE_LOADED = 'EDITOR_PAGE_LOADED'; 28 | export const EDITOR_PAGE_UNLOADED = 'EDITOR_PAGE_UNLOADED'; 29 | export const ADD_TAG = 'ADD_TAG'; 30 | export const REMOVE_TAG = 'REMOVE_TAG'; 31 | export const UPDATE_FIELD_AUTH = 'UPDATE_FIELD_AUTH'; 32 | export const UPDATE_FIELD_EDITOR = 'UPDATE_FIELD_EDITOR'; 33 | export const FOLLOW_USER = 'FOLLOW_USER'; 34 | export const UNFOLLOW_USER = 'UNFOLLOW_USER'; 35 | export const PROFILE_FAVORITES_PAGE_UNLOADED = 'PROFILE_FAVORITES_PAGE_UNLOADED'; 36 | export const PROFILE_FAVORITES_PAGE_LOADED = 'PROFILE_FAVORITES_PAGE_LOADED'; -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import ReactDOM from 'react-dom'; 2 | import { Provider } from 'react-redux'; 3 | import React from 'react'; 4 | import { store, history} from './store'; 5 | 6 | import { Route, Switch } from 'react-router-dom'; 7 | import { ConnectedRouter } from 'react-router-redux'; 8 | 9 | import App from './components/App'; 10 | 11 | ReactDOM.render(( 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | ), document.getElementById('root')); 21 | -------------------------------------------------------------------------------- /src/middleware.js: -------------------------------------------------------------------------------- 1 | import agent from './agent'; 2 | import { 3 | ASYNC_START, 4 | ASYNC_END, 5 | LOGIN, 6 | LOGOUT, 7 | REGISTER 8 | } from './constants/actionTypes'; 9 | 10 | const promiseMiddleware = store => next => action => { 11 | if (isPromise(action.payload)) { 12 | store.dispatch({ type: ASYNC_START, subtype: action.type }); 13 | 14 | const currentView = store.getState().viewChangeCounter; 15 | const skipTracking = action.skipTracking; 16 | 17 | action.payload.then( 18 | res => { 19 | const currentState = store.getState() 20 | if (!skipTracking && currentState.viewChangeCounter !== currentView) { 21 | return 22 | } 23 | console.log('RESULT', res); 24 | action.payload = res; 25 | store.dispatch({ type: ASYNC_END, promise: action.payload }); 26 | store.dispatch(action); 27 | }, 28 | error => { 29 | const currentState = store.getState() 30 | if (!skipTracking && currentState.viewChangeCounter !== currentView) { 31 | return 32 | } 33 | console.log('ERROR', error); 34 | action.error = true; 35 | action.payload = error.response.body; 36 | if (!action.skipTracking) { 37 | store.dispatch({ type: ASYNC_END, promise: action.payload }); 38 | } 39 | store.dispatch(action); 40 | } 41 | ); 42 | 43 | return; 44 | } 45 | 46 | next(action); 47 | }; 48 | 49 | const localStorageMiddleware = store => next => action => { 50 | if (action.type === REGISTER || action.type === LOGIN) { 51 | if (!action.error) { 52 | window.localStorage.setItem('jwt', action.payload.user.token); 53 | agent.setToken(action.payload.user.token); 54 | } 55 | } else if (action.type === LOGOUT) { 56 | window.localStorage.setItem('jwt', ''); 57 | agent.setToken(null); 58 | } 59 | 60 | next(action); 61 | }; 62 | 63 | function isPromise(v) { 64 | return v && typeof v.then === 'function'; 65 | } 66 | 67 | 68 | export { promiseMiddleware, localStorageMiddleware } 69 | -------------------------------------------------------------------------------- /src/reducer.js: -------------------------------------------------------------------------------- 1 | import article from './reducers/article'; 2 | import articleList from './reducers/articleList'; 3 | import auth from './reducers/auth'; 4 | import { combineReducers } from 'redux'; 5 | import common from './reducers/common'; 6 | import editor from './reducers/editor'; 7 | import home from './reducers/home'; 8 | import profile from './reducers/profile'; 9 | import settings from './reducers/settings'; 10 | import { routerReducer } from 'react-router-redux'; 11 | 12 | export default combineReducers({ 13 | article, 14 | articleList, 15 | auth, 16 | common, 17 | editor, 18 | home, 19 | profile, 20 | settings, 21 | router: routerReducer 22 | }); 23 | -------------------------------------------------------------------------------- /src/reducers/article.js: -------------------------------------------------------------------------------- 1 | import { 2 | ARTICLE_PAGE_LOADED, 3 | ARTICLE_PAGE_UNLOADED, 4 | ADD_COMMENT, 5 | DELETE_COMMENT 6 | } from '../constants/actionTypes'; 7 | 8 | export default (state = {}, action) => { 9 | switch (action.type) { 10 | case ARTICLE_PAGE_LOADED: 11 | return { 12 | ...state, 13 | article: action.payload[0].article, 14 | comments: action.payload[1].comments 15 | }; 16 | case ARTICLE_PAGE_UNLOADED: 17 | return {}; 18 | case ADD_COMMENT: 19 | return { 20 | ...state, 21 | commentErrors: action.error ? action.payload.errors : null, 22 | comments: action.error ? 23 | null : 24 | (state.comments || []).concat([action.payload.comment]) 25 | }; 26 | case DELETE_COMMENT: 27 | const commentId = action.commentId 28 | return { 29 | ...state, 30 | comments: state.comments.filter(comment => comment.id !== commentId) 31 | }; 32 | default: 33 | return state; 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /src/reducers/articleList.js: -------------------------------------------------------------------------------- 1 | import { 2 | ARTICLE_FAVORITED, 3 | ARTICLE_UNFAVORITED, 4 | SET_PAGE, 5 | APPLY_TAG_FILTER, 6 | HOME_PAGE_LOADED, 7 | HOME_PAGE_UNLOADED, 8 | CHANGE_TAB, 9 | PROFILE_PAGE_LOADED, 10 | PROFILE_PAGE_UNLOADED, 11 | PROFILE_FAVORITES_PAGE_LOADED, 12 | PROFILE_FAVORITES_PAGE_UNLOADED 13 | } from '../constants/actionTypes'; 14 | 15 | export default (state = {}, action) => { 16 | switch (action.type) { 17 | case ARTICLE_FAVORITED: 18 | case ARTICLE_UNFAVORITED: 19 | return { 20 | ...state, 21 | articles: state.articles.map(article => { 22 | if (action.payload.article && 23 | article.slug === action.payload.article.slug) { 24 | return { 25 | ...article, 26 | favorited: action.payload.article.favorited, 27 | favoritesCount: action.payload.article.favoritesCount 28 | }; 29 | } 30 | return article; 31 | }) 32 | }; 33 | case SET_PAGE: 34 | return { 35 | ...state, 36 | articles: action.payload.articles, 37 | articlesCount: action.payload.articlesCount, 38 | currentPage: action.page 39 | }; 40 | case APPLY_TAG_FILTER: 41 | return { 42 | ...state, 43 | pager: action.pager, 44 | articles: action.payload.articles, 45 | articlesCount: action.payload.articlesCount, 46 | tab: null, 47 | tag: action.tag, 48 | currentPage: 0 49 | }; 50 | case HOME_PAGE_LOADED: 51 | return { 52 | ...state, 53 | pager: action.pager, 54 | tags: action.payload[0].tags, 55 | articles: action.payload[1].articles, 56 | articlesCount: action.payload[1].articlesCount, 57 | currentPage: 0, 58 | tab: action.tab 59 | }; 60 | case HOME_PAGE_UNLOADED: 61 | return {}; 62 | case CHANGE_TAB: 63 | return { 64 | ...state, 65 | pager: action.pager, 66 | articles: action.payload.articles, 67 | articlesCount: action.payload.articlesCount, 68 | tab: action.tab, 69 | currentPage: 0, 70 | tag: null 71 | }; 72 | case PROFILE_PAGE_LOADED: 73 | case PROFILE_FAVORITES_PAGE_LOADED: 74 | return { 75 | ...state, 76 | pager: action.pager, 77 | articles: action.payload[1].articles, 78 | articlesCount: action.payload[1].articlesCount, 79 | currentPage: 0 80 | }; 81 | case PROFILE_PAGE_UNLOADED: 82 | case PROFILE_FAVORITES_PAGE_UNLOADED: 83 | return {}; 84 | default: 85 | return state; 86 | } 87 | }; 88 | -------------------------------------------------------------------------------- /src/reducers/auth.js: -------------------------------------------------------------------------------- 1 | import { 2 | LOGIN, 3 | REGISTER, 4 | LOGIN_PAGE_UNLOADED, 5 | REGISTER_PAGE_UNLOADED, 6 | ASYNC_START, 7 | UPDATE_FIELD_AUTH 8 | } from '../constants/actionTypes'; 9 | 10 | export default (state = {}, action) => { 11 | switch (action.type) { 12 | case LOGIN: 13 | case REGISTER: 14 | return { 15 | ...state, 16 | inProgress: false, 17 | errors: action.error ? action.payload.errors : null 18 | }; 19 | case LOGIN_PAGE_UNLOADED: 20 | case REGISTER_PAGE_UNLOADED: 21 | return {}; 22 | case ASYNC_START: 23 | if (action.subtype === LOGIN || action.subtype === REGISTER) { 24 | return { ...state, inProgress: true }; 25 | } 26 | break; 27 | case UPDATE_FIELD_AUTH: 28 | return { ...state, [action.key]: action.value }; 29 | default: 30 | return state; 31 | } 32 | 33 | return state; 34 | }; 35 | -------------------------------------------------------------------------------- /src/reducers/common.js: -------------------------------------------------------------------------------- 1 | import { 2 | APP_LOAD, 3 | REDIRECT, 4 | LOGOUT, 5 | ARTICLE_SUBMITTED, 6 | SETTINGS_SAVED, 7 | LOGIN, 8 | REGISTER, 9 | DELETE_ARTICLE, 10 | ARTICLE_PAGE_UNLOADED, 11 | EDITOR_PAGE_UNLOADED, 12 | HOME_PAGE_UNLOADED, 13 | PROFILE_PAGE_UNLOADED, 14 | PROFILE_FAVORITES_PAGE_UNLOADED, 15 | SETTINGS_PAGE_UNLOADED, 16 | LOGIN_PAGE_UNLOADED, 17 | REGISTER_PAGE_UNLOADED 18 | } from '../constants/actionTypes'; 19 | 20 | const defaultState = { 21 | appName: 'Awesome Twitter', 22 | token: null, 23 | viewChangeCounter: 0 24 | }; 25 | 26 | export default (state = defaultState, action) => { 27 | switch (action.type) { 28 | case APP_LOAD: 29 | return { 30 | ...state, 31 | token: action.token || null, 32 | appLoaded: true, 33 | currentUser: action.payload ? action.payload.user : null 34 | }; 35 | case REDIRECT: 36 | return { ...state, redirectTo: null }; 37 | case LOGOUT: 38 | return { ...state, redirectTo: '/', token: null, currentUser: null }; 39 | case ARTICLE_SUBMITTED: 40 | const redirectUrl = `/article/${action.payload.article.slug}`; 41 | return { ...state, redirectTo: redirectUrl }; 42 | case SETTINGS_SAVED: 43 | return { 44 | ...state, 45 | redirectTo: action.error ? null : '/', 46 | currentUser: action.error ? null : action.payload.user 47 | }; 48 | case LOGIN: 49 | case REGISTER: 50 | return { 51 | ...state, 52 | redirectTo: action.error ? null : '/', 53 | token: action.error ? null : action.payload.user.token, 54 | currentUser: action.error ? null : action.payload.user 55 | }; 56 | case DELETE_ARTICLE: 57 | return { ...state, redirectTo: '/' }; 58 | case ARTICLE_PAGE_UNLOADED: 59 | case EDITOR_PAGE_UNLOADED: 60 | case HOME_PAGE_UNLOADED: 61 | case PROFILE_PAGE_UNLOADED: 62 | case PROFILE_FAVORITES_PAGE_UNLOADED: 63 | case SETTINGS_PAGE_UNLOADED: 64 | case LOGIN_PAGE_UNLOADED: 65 | case REGISTER_PAGE_UNLOADED: 66 | return { ...state, viewChangeCounter: state.viewChangeCounter + 1 }; 67 | default: 68 | return state; 69 | } 70 | }; 71 | -------------------------------------------------------------------------------- /src/reducers/editor.js: -------------------------------------------------------------------------------- 1 | import { 2 | EDITOR_PAGE_LOADED, 3 | EDITOR_PAGE_UNLOADED, 4 | ARTICLE_SUBMITTED, 5 | ASYNC_START, 6 | ADD_TAG, 7 | REMOVE_TAG, 8 | UPDATE_FIELD_EDITOR 9 | } from '../constants/actionTypes'; 10 | 11 | export default (state = {}, action) => { 12 | switch (action.type) { 13 | case EDITOR_PAGE_LOADED: 14 | return { 15 | ...state, 16 | articleSlug: action.payload ? action.payload.article.slug : '', 17 | title: action.payload ? action.payload.article.title : '', 18 | description: action.payload ? action.payload.article.description : '', 19 | body: action.payload ? action.payload.article.body : '', 20 | tagInput: '', 21 | tagList: action.payload ? action.payload.article.tagList : [] 22 | }; 23 | case EDITOR_PAGE_UNLOADED: 24 | return {}; 25 | case ARTICLE_SUBMITTED: 26 | return { 27 | ...state, 28 | inProgress: null, 29 | errors: action.error ? action.payload.errors : null 30 | }; 31 | case ASYNC_START: 32 | if (action.subtype === ARTICLE_SUBMITTED) { 33 | return { ...state, inProgress: true }; 34 | } 35 | break; 36 | case ADD_TAG: 37 | return { 38 | ...state, 39 | tagList: state.tagList.concat([state.tagInput]), 40 | tagInput: '' 41 | }; 42 | case REMOVE_TAG: 43 | return { 44 | ...state, 45 | tagList: state.tagList.filter(tag => tag !== action.tag) 46 | }; 47 | case UPDATE_FIELD_EDITOR: 48 | return { ...state, [action.key]: action.value }; 49 | default: 50 | return state; 51 | } 52 | 53 | return state; 54 | }; 55 | -------------------------------------------------------------------------------- /src/reducers/home.js: -------------------------------------------------------------------------------- 1 | import { HOME_PAGE_LOADED, HOME_PAGE_UNLOADED } from '../constants/actionTypes'; 2 | 3 | export default (state = {}, action) => { 4 | switch (action.type) { 5 | case HOME_PAGE_LOADED: 6 | return { 7 | ...state, 8 | tags: action.payload[0].tags 9 | }; 10 | case HOME_PAGE_UNLOADED: 11 | return {}; 12 | default: 13 | return state; 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /src/reducers/profile.js: -------------------------------------------------------------------------------- 1 | import { 2 | PROFILE_PAGE_LOADED, 3 | PROFILE_PAGE_UNLOADED, 4 | FOLLOW_USER, 5 | UNFOLLOW_USER 6 | } from '../constants/actionTypes'; 7 | 8 | export default (state = {}, action) => { 9 | switch (action.type) { 10 | case PROFILE_PAGE_LOADED: 11 | return { 12 | ...action.payload[0].profile 13 | }; 14 | case PROFILE_PAGE_UNLOADED: 15 | return {}; 16 | case FOLLOW_USER: 17 | case UNFOLLOW_USER: 18 | return { 19 | ...action.payload.profile 20 | }; 21 | default: 22 | return state; 23 | } 24 | }; 25 | -------------------------------------------------------------------------------- /src/reducers/settings.js: -------------------------------------------------------------------------------- 1 | import { 2 | SETTINGS_SAVED, 3 | SETTINGS_PAGE_UNLOADED, 4 | ASYNC_START 5 | } from '../constants/actionTypes'; 6 | 7 | export default (state = {}, action) => { 8 | switch (action.type) { 9 | case SETTINGS_SAVED: 10 | return { 11 | ...state, 12 | inProgress: false, 13 | errors: action.error ? action.payload.errors : null 14 | }; 15 | case SETTINGS_PAGE_UNLOADED: 16 | return {}; 17 | case ASYNC_START: 18 | return { 19 | ...state, 20 | inProgress: true 21 | }; 22 | default: 23 | return state; 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /src/store.js: -------------------------------------------------------------------------------- 1 | import { applyMiddleware, createStore } from 'redux'; 2 | import { createLogger } from 'redux-logger' 3 | import { composeWithDevTools } from 'redux-devtools-extension/developmentOnly'; 4 | import { promiseMiddleware, localStorageMiddleware } from './middleware'; 5 | import reducer from './reducer'; 6 | 7 | import { routerMiddleware } from 'react-router-redux' 8 | import createHistory from 'history/createBrowserHistory'; 9 | 10 | export const history = createHistory(); 11 | 12 | // Build the middleware for intercepting and dispatching navigation actions 13 | const myRouterMiddleware = routerMiddleware(history); 14 | 15 | const getMiddleware = () => { 16 | if (process.env.NODE_ENV === 'production') { 17 | return applyMiddleware(myRouterMiddleware, promiseMiddleware, localStorageMiddleware); 18 | } else { 19 | // Enable additional logging in non-production environments. 20 | return applyMiddleware(myRouterMiddleware, promiseMiddleware, localStorageMiddleware, createLogger()) 21 | } 22 | }; 23 | 24 | export const store = createStore( 25 | reducer, composeWithDevTools(getMiddleware())); 26 | --------------------------------------------------------------------------------