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

11 |
12 |
13 |
14 |
15 | {article.author.username}
16 |
17 |
18 | {new Date(article.createdAt).toDateString()}
19 |
20 |
21 |
22 |
23 |
24 | );
25 | };
26 |
27 | export default ArticleMeta;
28 |
--------------------------------------------------------------------------------
/src/components/Article/Comment.js:
--------------------------------------------------------------------------------
1 | import DeleteButton from './DeleteButton';
2 | import { Link } from 'react-router-dom';
3 | import React from 'react';
4 |
5 | const Comment = props => {
6 | const comment = props.comment;
7 | const show = props.currentUser &&
8 | props.currentUser.username === comment.author.username;
9 | return (
10 |
11 |
14 |
15 |
18 |

19 |
20 |
21 |
24 | {comment.author.username}
25 |
26 |
27 | {new Date(comment.createdAt).toDateString()}
28 |
29 |
30 |
31 |
32 | );
33 | };
34 |
35 | export default Comment;
36 |
--------------------------------------------------------------------------------
/src/components/Article/CommentContainer.js:
--------------------------------------------------------------------------------
1 | import CommentInput from './CommentInput';
2 | import CommentList from './CommentList';
3 | import { Link } from 'react-router-dom';
4 | import React from 'react';
5 |
6 | const CommentContainer = props => {
7 | if (props.currentUser) {
8 | return (
9 |
10 |
11 |
12 |
13 |
14 |
15 |
19 |
20 | );
21 | } else {
22 | return (
23 |
24 |
25 | Sign in
26 | or
27 | sign up
28 | to add comments on this article.
29 |
30 |
31 |
35 |
36 | );
37 | }
38 | };
39 |
40 | export default CommentContainer;
41 |
--------------------------------------------------------------------------------
/src/components/Article/CommentInput.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import agent from '../../agent';
3 | import { connect } from 'react-redux';
4 | import { ADD_COMMENT } from '../../constants/actionTypes';
5 |
6 | const mapDispatchToProps = dispatch => ({
7 | onSubmit: payload =>
8 | dispatch({ type: ADD_COMMENT, payload })
9 | });
10 |
11 | class CommentInput extends React.Component {
12 | constructor() {
13 | super();
14 | this.state = {
15 | body: ''
16 | };
17 |
18 | this.setBody = ev => {
19 | this.setState({ body: ev.target.value });
20 | };
21 |
22 | this.createComment = ev => {
23 | ev.preventDefault();
24 | const payload = agent.Comments.create(this.props.slug,
25 | { body: this.state.body });
26 | this.setState({ body: '' });
27 | this.props.onSubmit(payload);
28 | };
29 | }
30 |
31 | render() {
32 | return (
33 |
54 | );
55 | }
56 | }
57 |
58 | export default connect(() => ({}), mapDispatchToProps)(CommentInput);
59 |
--------------------------------------------------------------------------------
/src/components/Article/CommentList.js:
--------------------------------------------------------------------------------
1 | import Comment from './Comment';
2 | import React from 'react';
3 |
4 | const CommentList = props => {
5 | return (
6 |
7 | {
8 | props.comments.map(comment => {
9 | return (
10 |
15 | );
16 | })
17 | }
18 |
19 | );
20 | };
21 |
22 | export default CommentList;
23 |
--------------------------------------------------------------------------------
/src/components/Article/DeleteButton.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import agent from '../../agent';
3 | import { connect } from 'react-redux';
4 | import { DELETE_COMMENT } from '../../constants/actionTypes';
5 |
6 | const mapDispatchToProps = dispatch => ({
7 | onClick: (payload, commentId) =>
8 | dispatch({ type: DELETE_COMMENT, payload, commentId })
9 | });
10 |
11 | const DeleteButton = props => {
12 | const del = () => {
13 | const payload = agent.Comments.delete(props.slug, props.commentId);
14 | props.onClick(payload, props.commentId);
15 | };
16 |
17 | if (props.show) {
18 | return (
19 |
20 |
21 |
22 | );
23 | }
24 | return null;
25 | };
26 |
27 | export default connect(() => ({}), mapDispatchToProps)(DeleteButton);
28 |
--------------------------------------------------------------------------------
/src/components/Article/index.js:
--------------------------------------------------------------------------------
1 | import ArticleMeta from './ArticleMeta';
2 | import CommentContainer from './CommentContainer';
3 | import React from 'react';
4 | import agent from '../../agent';
5 | import {
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 |

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 |
76 | {
77 | article.tagList.map(tag => {
78 | return (
79 | -
80 | {tag}
81 |
82 | )
83 | })
84 | }
85 |
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 |
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 |
8 |
9 | -
10 |
11 | Home
12 |
13 |
14 |
15 | -
16 |
17 | Sign in
18 |
19 |
20 |
21 | -
22 |
23 | Sign up
24 |
25 |
26 |
27 |
28 | );
29 | }
30 | return null;
31 | };
32 |
33 | const LoggedInView = props => {
34 | if (props.currentUser) {
35 | return (
36 |
37 |
38 | -
39 |
40 | Home
41 |
42 |
43 |
44 | -
45 |
46 | New Tweet
47 |
48 |
49 |
50 | -
51 |
52 | Profile
53 |
54 |
55 |
56 | -
57 |
60 |
61 | {props.currentUser.username}
62 |
63 |
64 |
65 |
66 | );
67 | }
68 |
69 | return null;
70 | };
71 |
72 | class Header extends React.Component {
73 | render() {
74 | return (
75 |
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 |
73 |
74 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
92 |
93 | );
94 | };
95 |
96 | export default connect(mapStateToProps, mapDispatchToProps)(MainView);
97 |
--------------------------------------------------------------------------------
/src/components/Home/Tags.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import agent from '../../agent';
3 |
4 | const Tags = props => {
5 | const tags = props.tags;
6 | if (tags) {
7 | return (
8 |
9 | {
10 | tags.map(tag => {
11 | const handleClick = ev => {
12 | ev.preventDefault();
13 | props.onClickTag(tag, page => agent.Articles.byTag(tag, page), agent.Articles.byTag(tag));
14 | };
15 |
16 | return (
17 |
22 | {tag}
23 |
24 | );
25 | })
26 | }
27 |
28 | );
29 | } else {
30 | return (
31 | Loading Tags...
32 | );
33 | }
34 | };
35 |
36 | export default Tags;
37 |
--------------------------------------------------------------------------------
/src/components/Home/index.js:
--------------------------------------------------------------------------------
1 | import Banner from './Banner';
2 | import MainView from './MainView';
3 | import React from 'react';
4 | import Tags from './Tags';
5 | import agent from '../../agent';
6 | import { connect } from 'react-redux';
7 | import {
8 | HOME_PAGE_LOADED,
9 | HOME_PAGE_UNLOADED,
10 | APPLY_TAG_FILTER
11 | } from '../../constants/actionTypes';
12 |
13 | const Promise = global.Promise;
14 |
15 | const mapStateToProps = state => ({
16 | ...state.home,
17 | appName: state.common.appName,
18 | token: state.common.token
19 | });
20 |
21 | const mapDispatchToProps = dispatch => ({
22 | onClickTag: (tag, pager, payload) =>
23 | dispatch({ type: APPLY_TAG_FILTER, tag, pager, payload }),
24 | onLoad: (tab, pager, payload) =>
25 | dispatch({ type: HOME_PAGE_LOADED, tab, pager, payload }),
26 | onUnload: () =>
27 | dispatch({ type: HOME_PAGE_UNLOADED })
28 | });
29 |
30 | class Home extends React.Component {
31 | componentWillMount() {
32 | const tab = this.props.token ? 'feed' : 'all';
33 | const articlesPromise = this.props.token ?
34 | agent.Articles.feed :
35 | agent.Articles.all;
36 |
37 | this.props.onLoad(tab, articlesPromise, Promise.all([agent.Tags.getAll(), articlesPromise()]));
38 | }
39 |
40 | componentWillUnmount() {
41 | this.props.onUnload();
42 | }
43 |
44 | render() {
45 | return (
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
Popular Tags
58 |
59 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 | );
70 | }
71 | }
72 |
73 | export default connect(mapStateToProps, mapDispatchToProps)(Home);
74 |
--------------------------------------------------------------------------------
/src/components/ListErrors.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | class ListErrors extends React.Component {
4 | render() {
5 | const errors = this.props.errors;
6 | if (errors) {
7 | return (
8 |
9 | {
10 | Object.keys(errors).map(key => {
11 | return (
12 | -
13 | {key} {errors[key]}
14 |
15 | );
16 | })
17 | }
18 |
19 | );
20 | } else {
21 | return null;
22 | }
23 | }
24 | }
25 |
26 | export default ListErrors;
27 |
--------------------------------------------------------------------------------
/src/components/ListPagination.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import agent from '../agent';
3 | import { connect } from 'react-redux';
4 | import { SET_PAGE } from '../constants/actionTypes';
5 |
6 | const mapDispatchToProps = dispatch => ({
7 | onSetPage: (page, payload) =>
8 | dispatch({ type: SET_PAGE, page, payload })
9 | });
10 |
11 | const ListPagination = props => {
12 | if (props.articlesCount <= 10) {
13 | return null;
14 | }
15 |
16 | const range = [];
17 | for (let i = 0; i < Math.ceil(props.articlesCount / 10); ++i) {
18 | range.push(i);
19 | }
20 |
21 | const setPage = page => {
22 | if(props.pager) {
23 | props.onSetPage(page, props.pager(page));
24 | }else {
25 | props.onSetPage(page, agent.Articles.all(page))
26 | }
27 | };
28 |
29 | return (
30 |
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 |
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 |
92 | -
93 |
96 | My Articles
97 |
98 |
99 |
100 | -
101 |
104 | Favorited Articles
105 |
106 |
107 |
108 | );
109 | }
110 |
111 | render() {
112 | const profile = this.props.profile;
113 | if (!profile) {
114 | return null;
115 | }
116 |
117 | const isUser = this.props.currentUser &&
118 | this.props.profile.username === this.props.currentUser.username;
119 |
120 | return (
121 |
122 |
123 |
124 |
125 |
126 |
127 |
128 |

129 |
{profile.username}
130 |
{profile.bio}
131 |
132 |
133 |
139 |
140 |
141 |
142 |
143 |
144 |
145 |
146 |
147 |
148 |
149 |
150 |
151 | {this.renderTabs()}
152 |
153 |
154 |
159 |
160 |
161 |
162 |
163 |
164 |
165 | );
166 | }
167 | }
168 |
169 | export default connect(mapStateToProps, mapDispatchToProps)(Profile);
170 | export { Profile, mapStateToProps };
171 |
--------------------------------------------------------------------------------
/src/components/ProfileFavorites.js:
--------------------------------------------------------------------------------
1 | import { Profile, mapStateToProps } from './Profile';
2 | import React from 'react';
3 | import { Link } from 'react-router-dom';
4 | import agent from '../agent';
5 | import { connect } from 'react-redux';
6 | import {
7 | PROFILE_PAGE_LOADED,
8 | PROFILE_PAGE_UNLOADED
9 | } from '../constants/actionTypes';
10 |
11 | const mapDispatchToProps = dispatch => ({
12 | onLoad: (pager, payload) =>
13 | dispatch({ type: PROFILE_PAGE_LOADED, pager, payload }),
14 | onUnload: () =>
15 | dispatch({ type: PROFILE_PAGE_UNLOADED })
16 | });
17 |
18 | class ProfileFavorites extends Profile {
19 | componentWillMount() {
20 | this.props.onLoad(page => agent.Articles.favoritedBy(this.props.match.params.username, page), Promise.all([
21 | agent.Profile.get(this.props.match.params.username),
22 | agent.Articles.favoritedBy(this.props.match.params.username)
23 | ]));
24 | }
25 |
26 | componentWillUnmount() {
27 | this.props.onUnload();
28 | }
29 |
30 | renderTabs() {
31 | return (
32 |
33 | -
34 |
37 | My Articles
38 |
39 |
40 |
41 | -
42 |
45 | Favorited Articles
46 |
47 |
48 |
49 | );
50 | }
51 | }
52 |
53 | export default connect(mapStateToProps, mapDispatchToProps)(ProfileFavorites);
54 |
--------------------------------------------------------------------------------
/src/components/Register.js:
--------------------------------------------------------------------------------
1 | import { Link } from 'react-router-dom';
2 | import ListErrors from './ListErrors';
3 | import React from 'react';
4 | import agent from '../agent';
5 | import { connect } from 'react-redux';
6 | import {
7 | UPDATE_FIELD_AUTH,
8 | REGISTER,
9 | REGISTER_PAGE_UNLOADED
10 | } from '../constants/actionTypes';
11 |
12 | const mapStateToProps = state => ({ ...state.auth });
13 |
14 | const mapDispatchToProps = dispatch => ({
15 | onChangeEmail: value =>
16 | dispatch({ type: UPDATE_FIELD_AUTH, key: 'email', value }),
17 | onChangePassword: value =>
18 | dispatch({ type: UPDATE_FIELD_AUTH, key: 'password', value }),
19 | onChangeUsername: value =>
20 | dispatch({ type: UPDATE_FIELD_AUTH, key: 'username', value }),
21 | onSubmit: (username, email, password) => {
22 | const payload = agent.Auth.register(username, email, password);
23 | dispatch({ type: REGISTER, payload })
24 | },
25 | onUnload: () =>
26 | dispatch({ type: REGISTER_PAGE_UNLOADED })
27 | });
28 |
29 | class Register extends React.Component {
30 | constructor() {
31 | super();
32 | this.changeEmail = ev => this.props.onChangeEmail(ev.target.value);
33 | this.changePassword = ev => this.props.onChangePassword(ev.target.value);
34 | this.changeUsername = ev => this.props.onChangeUsername(ev.target.value);
35 | this.submitForm = (username, email, password) => ev => {
36 | ev.preventDefault();
37 | this.props.onSubmit(username, email, password);
38 | }
39 | }
40 |
41 | componentWillUnmount() {
42 | this.props.onUnload();
43 | }
44 |
45 | render() {
46 | const email = this.props.email;
47 | const password = this.props.password;
48 | const username = this.props.username;
49 |
50 | return (
51 |
109 | );
110 | }
111 | }
112 |
113 | export default connect(mapStateToProps, mapDispatchToProps)(Register);
114 |
--------------------------------------------------------------------------------
/src/components/Settings.js:
--------------------------------------------------------------------------------
1 | import ListErrors from './ListErrors';
2 | import React from 'react';
3 | import agent from '../agent';
4 | import { connect } from 'react-redux';
5 | import {
6 | SETTINGS_SAVED,
7 | SETTINGS_PAGE_UNLOADED,
8 | LOGOUT
9 | } from '../constants/actionTypes';
10 |
11 | class SettingsForm extends React.Component {
12 | constructor() {
13 | super();
14 |
15 | this.state = {
16 | image: '',
17 | username: '',
18 | bio: '',
19 | email: '',
20 | password: ''
21 | };
22 |
23 | this.updateState = field => ev => {
24 | const state = this.state;
25 | const newState = Object.assign({}, state, { [field]: ev.target.value });
26 | this.setState(newState);
27 | };
28 |
29 | this.submitForm = ev => {
30 | ev.preventDefault();
31 |
32 | const user = Object.assign({}, this.state);
33 | if (!user.password) {
34 | delete user.password;
35 | }
36 |
37 | this.props.onSubmitForm(user);
38 | };
39 | }
40 |
41 | componentWillMount() {
42 | if (this.props.currentUser) {
43 | Object.assign(this.state, {
44 | image: this.props.currentUser.image || '',
45 | username: this.props.currentUser.username,
46 | bio: this.props.currentUser.bio,
47 | email: this.props.currentUser.email
48 | });
49 | }
50 | }
51 |
52 | componentWillReceiveProps(nextProps) {
53 | if (nextProps.currentUser) {
54 | this.setState(Object.assign({}, this.state, {
55 | image: nextProps.currentUser.image || '',
56 | username: nextProps.currentUser.username,
57 | bio: nextProps.currentUser.bio,
58 | email: nextProps.currentUser.email
59 | }));
60 | }
61 | }
62 |
63 | render() {
64 | return (
65 |
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 |
--------------------------------------------------------------------------------