├── .gitignore ├── README.md ├── package-lock.json ├── package.json ├── public └── index.html └── src ├── actions ├── actions.js └── constants.js ├── agent.js ├── apiUtils.js ├── components ├── App.js ├── BlogPost.js ├── BlogPostContainer.js ├── BlogPostForm.js ├── BlogPostList.js ├── BlogPostListContainer.js ├── CommentForm.js ├── CommentList.css ├── CommentList.js ├── CommentListContainer.js ├── ConfirmationForm.js ├── Header.js ├── ImageBrowser.js ├── ImageUpload.css ├── ImageUpload.js ├── LoadMore.js ├── LoginForm.js ├── Message.js ├── Paginator.js ├── RegisterForm.js ├── RegistrationContainer.js └── Spinner.js ├── form.js ├── index.js ├── middleware.js ├── reducer.js └── reducers ├── auth.js ├── blogPost.js ├── blogPostForm.js ├── blogPostList.js ├── commentList.js └── registration.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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |

Piotr Jura - Udemy Instructor

3 |

Piotr Jura Udemy Courses

4 |

Fado Code Camp

5 |
6 | 7 |

8 | High-quality, comprehensive courses for web developers. 9 |

10 | 11 |

12 | About the Instructor · 13 | Courses · 14 | Contact & Links · 15 | This Course Resources 16 |

17 |
18 | 19 | ## About the Instructor 20 | 21 | I am Piotr Jura, a seasoned web developer and a passionate Udemy instructor. With years of experience in JavaScript, TypeScript, Node, PHP, MySQL, Vue, React, and more, I bring practical, real-world knowledge to my students. 22 | 23 | ## Courses 24 | 25 | - [Master Nuxt 3 - Full-Stack Complete Guide](https://www.udemy.com/course/master-nuxt-full-stack-complete-guide/?referralCode=4EBA58BFBD39A31A9BE9) 26 | - [Symfony 6 Framework Hands-On 2023](https://www.udemy.com/course/symfony-framework-hands-on/?referralCode=6750F64C057515A5F787) 27 | - [Vue 3 Mastery: Firebase & More - Learn by Doing!](https://www.udemy.com/course/vuejs-course/?referralCode=26DAD96DAB47B4602DA3) 28 | - [Master NestJS - Node.js Framework 2023](https://www.udemy.com/course/master-nestjs-the-javascript-nodejs-framework/?referralCode=C8A3F83982053A5E44C0) 29 | - [Master Laravel with GraphQL, Vue.js, and Tailwind](https://www.udemy.com/course/master-laravel-with-graphql-vuejs-and-tailwind/?referralCode=CE3B5297B3614EFA884A) 30 | - [Master Laravel, Vue 3 & Inertia Full Stack 2023](https://www.udemy.com/course/master-laravel-6-with-vuejs-fullstack-development/?referralCode=4A6CED7AA1583CB709D6) 31 | - [Master Laravel 10 for Beginners & Intermediate 2023](https://www.udemy.com/course/laravel-beginner-fundamentals/?referralCode=E86A873AC47FB438D79C) 32 | - [Symfony API Platform with React Full Stack Masterclass](https://www.udemy.com/course/symfony-api-platform-reactjs-full-stack-masterclass/?referralCode=D2C29D1C641BB0CDBCD4) 33 | 34 | ## Contact and Links 35 | 36 | - **Blog:** [Fado Code Camp](https://fadocodecamp.com/) 37 | - **LinkedIn:** [Follow Me on LinkedIn](https://www.linkedin.com/in/piotr-j-24250b257/) 38 | - **GitHub:** You are here! Give me a follow! 39 | - **Twitter:** [@piotr_jura](https://twitter.com/piotr_jura) 40 | 41 | ## Course Resources 42 | 43 | Coming up! 44 | 45 | --- 46 | 47 |

48 | Explore, Learn, and Grow with My Comprehensive Web Development Courses! 49 |

50 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "api-platform-react-app-course", 3 | "version": "1.0.0", 4 | "private": true, 5 | "devDependencies": { 6 | "cross-env": "^5.1.4", 7 | "react-scripts": "1.1.1" 8 | }, 9 | "dependencies": { 10 | "classnames": "^2.2.6", 11 | "history": "^4.6.3", 12 | "marked": "^0.3.6", 13 | "prop-types": "^15.5.10", 14 | "react": "^16.3.0", 15 | "react-dom": "^16.3.0", 16 | "react-redux": "^5.0.7", 17 | "react-router": "^4.1.2", 18 | "react-router-dom": "^4.1.2", 19 | "react-router-redux": "^5.0.0-alpha.6", 20 | "react-transition-group": "^2.5.0", 21 | "redux": "^3.6.0", 22 | "redux-devtools-extension": "^2.13.2", 23 | "redux-form": "^7.4.2", 24 | "redux-logger": "^3.0.1", 25 | "redux-thunk": "^2.3.0", 26 | "superagent": "^3.8.2", 27 | "superagent-promise": "^1.1.0", 28 | "timeago.js": "^3.0.2" 29 | }, 30 | "scripts": { 31 | "start": "cross-env PORT=4100 react-scripts start", 32 | "build": "react-scripts build", 33 | "test": "cross-env PORT=4100 react-scripts test --env=jsdom", 34 | "eject": "react-scripts eject" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | API Platform Blog 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/actions/actions.js: -------------------------------------------------------------------------------- 1 | import {requests} from "../agent"; 2 | import { 3 | BLOG_POST_ERROR, BLOG_POST_FORM_UNLOAD, 4 | BLOG_POST_LIST_ERROR, 5 | BLOG_POST_LIST_RECEIVED, 6 | BLOG_POST_LIST_REQUEST, 7 | BLOG_POST_LIST_SET_PAGE, 8 | BLOG_POST_RECEIVED, 9 | BLOG_POST_REQUEST, 10 | BLOG_POST_UNLOAD, 11 | COMMENT_ADDED, 12 | COMMENT_LIST_ERROR, 13 | COMMENT_LIST_RECEIVED, 14 | COMMENT_LIST_REQUEST, 15 | COMMENT_LIST_UNLOAD, IMAGE_DELETE_REQUEST, IMAGE_DELETED, 16 | IMAGE_UPLOAD_ERROR, 17 | IMAGE_UPLOAD_REQUEST, 18 | IMAGE_UPLOADED, 19 | USER_CONFIRMATION_SUCCESS, 20 | USER_LOGIN_SUCCESS, 21 | USER_LOGOUT, 22 | USER_PROFILE_ERROR, 23 | USER_PROFILE_RECEIVED, 24 | USER_PROFILE_REQUEST, 25 | USER_REGISTER_COMPLETE, 26 | USER_REGISTER_SUCCESS, 27 | USER_SET_ID 28 | } from "./constants"; 29 | import {SubmissionError} from "redux-form"; 30 | import {parseApiErrors} from "../apiUtils"; 31 | 32 | export const blogPostListRequest = () => ({ 33 | type: BLOG_POST_LIST_REQUEST, 34 | }); 35 | 36 | export const blogPostListError = (error) => ({ 37 | type: BLOG_POST_LIST_ERROR, 38 | error 39 | }); 40 | 41 | export const blogPostListReceived = (data) => ({ 42 | type: BLOG_POST_LIST_RECEIVED, 43 | data 44 | }); 45 | 46 | export const blogPostListSetPage = (page) => ({ 47 | type: BLOG_POST_LIST_SET_PAGE, 48 | page 49 | }); 50 | 51 | export const blogPostListFetch = (page = 1) => { 52 | return (dispatch) => { 53 | dispatch(blogPostListRequest()); 54 | return requests.get(`/blog_posts?_page=${page}`) 55 | .then(response => dispatch(blogPostListReceived(response))) 56 | .catch(error => dispatch(blogPostListError(error))); 57 | } 58 | }; 59 | 60 | export const blogPostRequest = () => ({ 61 | type: BLOG_POST_REQUEST, 62 | }); 63 | 64 | export const blogPostError = (error) => ({ 65 | type: BLOG_POST_ERROR, 66 | error 67 | }); 68 | 69 | export const blogPostReceived = (data) => ({ 70 | type: BLOG_POST_RECEIVED, 71 | data 72 | }); 73 | 74 | export const blogPostUnload = () => ({ 75 | type: BLOG_POST_UNLOAD, 76 | }); 77 | 78 | export const blogPostFetch = (id) => { 79 | return (dispatch) => { 80 | dispatch(blogPostRequest()); 81 | return requests.get(`/blog_posts/${id}`) 82 | .then(response => dispatch(blogPostReceived(response))) 83 | .catch(error => dispatch(blogPostError(error))); 84 | } 85 | }; 86 | 87 | export const blogPostAdd = (title, content, images = []) => { 88 | return (dispatch) => { 89 | return requests.post( 90 | '/blog_posts', 91 | { 92 | title, 93 | content, 94 | slug: title && title.replace(/ /g, "-").toLowerCase(), 95 | images: images.map(image => `/api/images/${image.id}`) 96 | } 97 | ).catch((error) => { 98 | if (401 === error.response.status) { 99 | return dispatch(userLogout()); 100 | } else if (403 === error.response.status) { 101 | throw new SubmissionError({ 102 | _error: 'You do not have rights to publish blog posts!' 103 | }); 104 | } 105 | throw new SubmissionError(parseApiErrors(error)); 106 | }) 107 | } 108 | }; 109 | 110 | export const blogPostFormUnload = () => ({ 111 | type: BLOG_POST_FORM_UNLOAD 112 | }); 113 | 114 | export const commentListRequest = () => ({ 115 | type: COMMENT_LIST_REQUEST, 116 | }); 117 | 118 | export const commentListError = (error) => ({ 119 | type: COMMENT_LIST_ERROR, 120 | error 121 | }); 122 | 123 | export const commentListReceived = (data) => ({ 124 | type: COMMENT_LIST_RECEIVED, 125 | data 126 | }); 127 | 128 | export const commentListUnload = () => ({ 129 | type: COMMENT_LIST_UNLOAD, 130 | }); 131 | 132 | export const commentListFetch = (id, page = 1) => { 133 | return (dispatch) => { 134 | dispatch(commentListRequest()); 135 | return requests.get(`/blog_posts/${id}/comments?_page=${page}`) 136 | .then(response => dispatch(commentListReceived(response))) 137 | .catch(error => dispatch(commentListError(error))); 138 | } 139 | }; 140 | 141 | export const commentAdded = (comment) => ({ 142 | type: COMMENT_ADDED, 143 | comment 144 | }); 145 | 146 | export const commentAdd = (comment, blogPostId) => { 147 | return (dispatch) => { 148 | return requests.post( 149 | '/comments', 150 | { 151 | content: comment, 152 | blogPost: `/api/blog_posts/${blogPostId}` 153 | } 154 | ).then( 155 | response => dispatch(commentAdded(response)) 156 | ).catch((error) => { 157 | if (401 === error.response.status) { 158 | return dispatch(userLogout()); 159 | } 160 | throw new SubmissionError(parseApiErrors(error)); 161 | }) 162 | } 163 | }; 164 | 165 | export const userLoginSuccess = (token, userId) => { 166 | return { 167 | type: USER_LOGIN_SUCCESS, 168 | token, 169 | userId 170 | } 171 | }; 172 | 173 | export const userLoginAttempt = (username, password) => { 174 | return (dispatch) => { 175 | return requests.post('/login_check', {username, password}, false).then( 176 | response => dispatch(userLoginSuccess(response.token, response.id)) 177 | ).catch(() => { 178 | throw new SubmissionError({ 179 | _error: 'Username or password is invalid' 180 | }) 181 | }); 182 | } 183 | }; 184 | 185 | export const userLogout = () => { 186 | return { 187 | type: USER_LOGOUT 188 | } 189 | }; 190 | 191 | export const userRegisterSuccess = () => { 192 | return { 193 | type: USER_REGISTER_SUCCESS 194 | } 195 | }; 196 | 197 | export const userRegister = (username, password, retypedPassword, email, name) => { 198 | return (dispatch) => { 199 | return requests.post('/users', {username, password, retypedPassword, email, name}, false) 200 | .then(() => dispatch(userRegisterSuccess())) 201 | .catch(error => { 202 | throw new SubmissionError(parseApiErrors(error)); 203 | }); 204 | } 205 | }; 206 | 207 | export const userConfirmationSuccess = () => { 208 | return { 209 | type: USER_CONFIRMATION_SUCCESS 210 | } 211 | }; 212 | 213 | export const userRegisterComplete = () => { 214 | return { 215 | type: USER_REGISTER_COMPLETE 216 | } 217 | }; 218 | 219 | export const userConfirm = (confirmationToken) => { 220 | return (dispatch) => { 221 | return requests.post('/users/confirm', {confirmationToken}, false) 222 | .then(() => dispatch(userConfirmationSuccess())) 223 | .catch(error => { 224 | throw new SubmissionError({ 225 | _error: 'Confirmation token is invalid' 226 | }); 227 | }); 228 | } 229 | }; 230 | 231 | export const userSetId = (userId) => { 232 | return { 233 | type: USER_SET_ID, 234 | userId 235 | } 236 | }; 237 | 238 | export const userProfileRequest = () => { 239 | return { 240 | type: USER_PROFILE_REQUEST 241 | } 242 | }; 243 | 244 | export const userProfileError = (userId) => { 245 | return { 246 | type: USER_PROFILE_ERROR, 247 | userId 248 | } 249 | }; 250 | 251 | export const userProfileReceived = (userId, userData) => { 252 | return { 253 | type: USER_PROFILE_RECEIVED, 254 | userData, 255 | userId 256 | } 257 | }; 258 | 259 | export const userProfileFetch = (userId) => { 260 | return (dispatch) => { 261 | dispatch(userProfileRequest()); 262 | return requests.get(`/users/${userId}`, true).then( 263 | response => dispatch(userProfileReceived(userId, response)) 264 | ).catch(() => dispatch(userProfileError(userId))) 265 | } 266 | }; 267 | 268 | export const imageUploaded = (data) => { 269 | return { 270 | type: IMAGE_UPLOADED, 271 | image: data 272 | } 273 | }; 274 | 275 | export const imageUploadRequest = () => { 276 | return { 277 | type: IMAGE_UPLOAD_REQUEST, 278 | } 279 | }; 280 | 281 | export const imageUploadError = () => { 282 | return { 283 | type: IMAGE_UPLOAD_ERROR, 284 | } 285 | }; 286 | 287 | export const imageUpload = (file) => { 288 | return (dispatch) => { 289 | dispatch(imageUploadRequest()); 290 | return requests.upload('/images', file) 291 | .then(response => dispatch(imageUploaded(response))) 292 | .catch(() => dispatch(imageUploadError)) 293 | } 294 | }; 295 | 296 | export const imageDeleteRequest = () => { 297 | return { 298 | type: IMAGE_DELETE_REQUEST, 299 | } 300 | }; 301 | 302 | export const imageDelete = (id) => { 303 | return (dispatch) => { 304 | dispatch(imageDeleteRequest()); 305 | return requests.delete(`/images/${id}`) 306 | .then(() => dispatch(imageDeleted(id))); 307 | } 308 | }; 309 | 310 | export const imageDeleted = (id) => { 311 | return { 312 | type: IMAGE_DELETED, 313 | imageId: id 314 | } 315 | }; 316 | -------------------------------------------------------------------------------- /src/actions/constants.js: -------------------------------------------------------------------------------- 1 | export const BLOG_POST_LIST_REQUEST = 'BLOG_POST_LIST_REQUEST'; 2 | export const BLOG_POST_LIST_RECEIVED = 'BLOG_POST_LIST_RECEIVED'; 3 | export const BLOG_POST_LIST_ERROR = 'BLOG_POST_LIST_ERROR'; 4 | export const BLOG_POST_LIST_ADD = 'BLOG_POST_LIST_ADD'; 5 | export const BLOG_POST_LIST_SET_PAGE = 'BLOG_POST_LIST_SET_PAGE'; 6 | 7 | export const BLOG_POST_REQUEST = 'BLOG_POST_REQUEST'; 8 | export const BLOG_POST_RECEIVED = 'BLOG_POST_RECEIVED'; 9 | export const BLOG_POST_ERROR = 'BLOG_POST_ERROR'; 10 | export const BLOG_POST_UNLOAD = 'BLOG_POST_UNLOAD'; 11 | export const BLOG_POST_FORM_UNLOAD = 'BLOG_POST_FORM_UNLOAD'; 12 | 13 | export const COMMENT_LIST_REQUEST = 'COMMENT_LIST_REQUEST'; 14 | export const COMMENT_LIST_RECEIVED = 'COMMENT_LIST_RECEIVED'; 15 | export const COMMENT_LIST_ERROR = 'COMMENT_LIST_ERROR'; 16 | export const COMMENT_LIST_UNLOAD = 'COMMENT_LIST_UNLOAD'; 17 | 18 | export const COMMENT_ADDED = 'COMMENT_ADDED'; 19 | 20 | export const USER_LOGIN_SUCCESS = 'USER_LOGIN_SUCCESS'; 21 | export const USER_LOGOUT = 'USER_LOGOUT'; 22 | export const USER_SET_ID = 'USER_SET_ID'; 23 | export const USER_REGISTER_SUCCESS = 'USER_REGISTER_SUCCESS'; 24 | export const USER_REGISTER_COMPLETE = 'USER_REGISTER_COMPLETE'; 25 | export const USER_CONFIRMATION_SUCCESS = 'USER_CONFIRMATION_SUCCESS'; 26 | 27 | export const USER_PROFILE_REQUEST = 'USER_PROFILE_REQUEST'; 28 | export const USER_PROFILE_RECEIVED = 'USER_PROFILE_RECEIVED'; 29 | export const USER_PROFILE_ERROR = 'USER_PROFILE_ERROR'; 30 | 31 | export const IMAGE_UPLOADED = 'IMAGE_UPLOADED'; 32 | export const IMAGE_UPLOAD_REQUEST = 'IMAGE_UPLOAD_REQUEST'; 33 | export const IMAGE_UPLOAD_ERROR = 'IMAGE_UPLOAD_ERROR'; 34 | export const IMAGE_DELETED = 'IMAGE_DELETED'; 35 | export const IMAGE_DELETE_REQUEST = 'IMAGE_DELETE_REQUEST'; 36 | 37 | -------------------------------------------------------------------------------- /src/agent.js: -------------------------------------------------------------------------------- 1 | import superagentPromise from 'superagent-promise'; 2 | import _superagent from 'superagent'; 3 | 4 | const superagent = superagentPromise(_superagent, global.Promise); 5 | const API_ROOT = 'http://localhost:8000/api'; 6 | const responseBody = response => response.body; 7 | 8 | let token = null; 9 | 10 | const tokenPlugin = secured => { 11 | return (request) => { 12 | if (token && secured) { 13 | request.set('Authorization', `Bearer ${token}`); 14 | } 15 | }; 16 | }; 17 | 18 | export const requests = { 19 | get: (url, secured = false) => { 20 | return superagent.get(`${API_ROOT}${url}`).use(tokenPlugin(secured)).then(responseBody); 21 | }, 22 | post: (url, body = null, secured = true) => { 23 | return superagent.post(`${API_ROOT}${url}`, body).use(tokenPlugin(secured)).then(responseBody); 24 | }, 25 | upload: (url, file, secured = true) => 26 | superagent.post(`${API_ROOT}${url}`).attach('file', file) 27 | .use(tokenPlugin(secured)) 28 | .then(responseBody), 29 | delete: (url, secured = true) => { 30 | return superagent.del(`${API_ROOT}${url}`).use(tokenPlugin(secured)).then(responseBody) 31 | }, 32 | setToken: (newJwtToken) => token = newJwtToken 33 | }; 34 | -------------------------------------------------------------------------------- /src/apiUtils.js: -------------------------------------------------------------------------------- 1 | export const parseApiErrors = (error) => { 2 | return error.response.body.violations.reduce( 3 | (parsedErrors, violation) => { 4 | parsedErrors[violation['propertyPath']] = violation['message']; 5 | return parsedErrors; 6 | }, 7 | {} 8 | ); 9 | }; 10 | 11 | export const hydraPageCount = (collection) => { 12 | if (!collection['hydra:view']) { 13 | return 1; 14 | } 15 | 16 | return Number( 17 | collection['hydra:view']['hydra:last'].match(/page=(\d+)/)[1] 18 | ); 19 | }; 20 | 21 | const canWriteBlogPostRoles = ['ROLE_WRITER', 'ROLE_ADMIN', 'ROLE_SUPERADMIN']; 22 | 23 | export const canWriteBlogPost = (userData) => { 24 | return null !== userData 25 | && userData.roles.some( 26 | userRoles => canWriteBlogPostRoles.includes(userRoles) 27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /src/components/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {Route, Switch} from "react-router"; 3 | import LoginForm from "./LoginForm"; 4 | import BlogPostListContainer from "./BlogPostListContainer"; 5 | import Header from "./Header"; 6 | import BlogPostContainer from "./BlogPostContainer"; 7 | import {requests} from "../agent"; 8 | import {connect} from "react-redux"; 9 | import {userLogout, userProfileFetch, userSetId} from "../actions/actions"; 10 | import RegistrationContainer from "./RegistrationContainer"; 11 | import BlogPostForm from "./BlogPostForm"; 12 | 13 | const mapStateToProps = state => ({ 14 | ...state.auth 15 | }); 16 | 17 | const mapDispatchToProps = { 18 | userProfileFetch, userSetId, userLogout 19 | }; 20 | 21 | class App extends React.Component { 22 | constructor(props) { 23 | super(props); 24 | const token = window.localStorage.getItem('jwtToken'); 25 | 26 | if (token) { 27 | requests.setToken(token); 28 | } 29 | } 30 | 31 | componentDidMount() { 32 | const userId = window.localStorage.getItem('userId'); 33 | const {userSetId} = this.props; 34 | 35 | if (userId) { 36 | userSetId(userId); 37 | } 38 | } 39 | 40 | componentDidUpdate(prevProps) { 41 | const {userId, userData, userProfileFetch} = this.props; 42 | 43 | if (prevProps.userId !== userId && userId !== null && userData === null) { 44 | userProfileFetch(userId); 45 | } 46 | } 47 | 48 | render() { 49 | const {isAuthenticated, userData, userLogout} = this.props; 50 | 51 | return ( 52 |
53 |
54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 |
62 | ) 63 | } 64 | } 65 | 66 | export default connect(mapStateToProps, mapDispatchToProps)(App); 67 | -------------------------------------------------------------------------------- /src/components/BlogPost.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import timeago from 'timeago.js'; 3 | import {Message} from "./Message"; 4 | 5 | export class BlogPost extends React.Component { 6 | render() { 7 | const {post} = this.props; 8 | 9 | if (null === post) { 10 | return (); 11 | } 12 | 13 | return ( 14 |
15 |
16 |

{post.title}

17 |

{post.content}

18 |

19 | 20 | {timeago().format(post.published)} by  21 | {post.author.name} 22 | 23 |

24 |
25 |
26 | ) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/components/BlogPostContainer.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {blogPostFetch, blogPostUnload} from "../actions/actions"; 3 | import {connect} from "react-redux"; 4 | import {BlogPost} from "./BlogPost"; 5 | import {Spinner} from "./Spinner"; 6 | import CommentListContainer from "./CommentListContainer"; 7 | 8 | const mapeStateToProps = state => ({ 9 | ...state.blogPost 10 | }); 11 | 12 | const mapDispatchToProps = { 13 | blogPostFetch, 14 | blogPostUnload 15 | }; 16 | 17 | class BlogPostContainer extends React.Component { 18 | componentDidMount() { 19 | this.props.blogPostFetch(this.props.match.params.id); 20 | } 21 | 22 | componentWillUnmount() { 23 | this.props.blogPostUnload(); 24 | } 25 | 26 | render() { 27 | const {isFetching, post} = this.props; 28 | 29 | if (isFetching) { 30 | return (); 31 | } 32 | 33 | return ( 34 |
35 | 36 | {post && } 37 |
38 | ) 39 | } 40 | } 41 | 42 | export default connect(mapeStateToProps, mapDispatchToProps)(BlogPostContainer); 43 | -------------------------------------------------------------------------------- /src/components/BlogPostForm.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {Field, reduxForm} from "redux-form"; 3 | import {connect} from "react-redux"; 4 | import {canWriteBlogPost} from "../apiUtils"; 5 | import {Redirect} from "react-router"; 6 | import {renderField} from "../form"; 7 | import {blogPostAdd, blogPostFormUnload, imageDelete} from "../actions/actions"; 8 | import ImageUpload from "./ImageUpload"; 9 | import {ImageBrowser} from "./ImageBrowser"; 10 | 11 | const mapDispatchToProps = { 12 | blogPostAdd, 13 | blogPostFormUnload, 14 | imageDelete 15 | }; 16 | 17 | const mapStateToProps = state => ({ 18 | userData: state.auth.userData, 19 | ...state.blogPostForm 20 | }); 21 | 22 | class BlogPostForm extends React.Component { 23 | onSubmit(values) { 24 | const {blogPostAdd, reset, history, images} = this.props; 25 | 26 | return blogPostAdd(values.title, values.content, images) 27 | .then(() => { 28 | reset(); 29 | history.push('/'); 30 | }); 31 | } 32 | 33 | componentWillUnmount() { 34 | this.props.blogPostFormUnload(); 35 | } 36 | 37 | render() { 38 | if (!canWriteBlogPost(this.props.userData)) { 39 | return 40 | } 41 | 42 | const {submitting, handleSubmit, error, images, imageReqInProgress, imageDelete} = this.props; 43 | 44 | return ( 45 |
46 |
47 | {error &&
{error}
} 48 |
49 | 50 | 51 | 52 | 53 | 56 | 57 | 61 | 62 |
63 |
64 | ) 65 | } 66 | } 67 | 68 | export default reduxForm({ 69 | form: 'BlogPostForm' 70 | })(connect(mapStateToProps, mapDispatchToProps)(BlogPostForm)) 71 | -------------------------------------------------------------------------------- /src/components/BlogPostList.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import timeago from 'timeago.js'; 3 | import {Link} from "react-router-dom"; 4 | import {Message} from "./Message"; 5 | 6 | class BlogPostList extends React.Component { 7 | render() { 8 | const {posts} = this.props; 9 | 10 | if (null === posts || 0 === posts.length) { 11 | return (); 12 | } 13 | 14 | return (
15 | {posts && posts.map(post => ( 16 |
17 |
18 |

19 | {post.title} 20 |

21 |

22 | 23 | {timeago().format(post.published)} 24 | 25 |

26 |
27 |
28 | ))} 29 |
) 30 | } 31 | } 32 | 33 | export default BlogPostList; 34 | -------------------------------------------------------------------------------- /src/components/BlogPostListContainer.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import BlogPostList from "./BlogPostList"; 3 | import {blogPostListFetch, blogPostListSetPage} from "../actions/actions"; 4 | import {connect} from "react-redux"; 5 | import {Spinner} from "./Spinner"; 6 | import {Paginator} from "./Paginator"; 7 | 8 | const mapStateToProps = state => ({ 9 | ...state.blogPostList 10 | }); 11 | 12 | const mapDispatchToProps = { 13 | blogPostListFetch, blogPostListSetPage 14 | }; 15 | 16 | class BlogPostListContainer extends React.Component { 17 | componentDidMount() { 18 | this.props.blogPostListFetch(this.getQueryParamPage()); 19 | } 20 | 21 | componentDidUpdate(prevProps) { 22 | const {currentPage, blogPostListFetch, blogPostListSetPage} = this.props; 23 | 24 | if (prevProps.match.params.page !== this.getQueryParamPage()) { 25 | blogPostListSetPage(this.getQueryParamPage()); 26 | } 27 | 28 | if (prevProps.currentPage !== currentPage) { 29 | blogPostListFetch(currentPage); 30 | } 31 | } 32 | 33 | getQueryParamPage() { 34 | return Number(this.props.match.params.page) || 1; 35 | } 36 | 37 | changePage(page) { 38 | const {history, blogPostListSetPage} = this.props; 39 | blogPostListSetPage(page); 40 | history.push(`/${page}`); 41 | } 42 | 43 | onNextPageClick(e) { 44 | const {currentPage, pageCount} = this.props; 45 | const newPage = Math.min(currentPage + 1, pageCount); 46 | this.changePage(newPage); 47 | } 48 | 49 | onPrevPageClick(e) { 50 | const {currentPage} = this.props; 51 | const newPage = Math.max(currentPage - 1, 1); 52 | this.changePage(newPage); 53 | } 54 | 55 | render() { 56 | const {posts, isFetching, currentPage, pageCount} = this.props; 57 | 58 | if (isFetching) { 59 | return (); 60 | } 61 | 62 | return ( 63 |
64 | 65 | 69 |
70 | ) 71 | } 72 | } 73 | 74 | export default connect(mapStateToProps, mapDispatchToProps)(BlogPostListContainer); 75 | -------------------------------------------------------------------------------- /src/components/CommentForm.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {Field, reduxForm} from "redux-form"; 3 | import {connect} from "react-redux"; 4 | import {renderField} from "../form"; 5 | import {commentAdd} from "../actions/actions"; 6 | 7 | const mapDispatchToProps = { 8 | commentAdd 9 | }; 10 | 11 | class CommentForm extends React.Component { 12 | onSubmit(values) { 13 | const {commentAdd, blogPostId, reset} = this.props; 14 | return commentAdd(values.content, blogPostId).then(() => reset()); 15 | } 16 | 17 | render() { 18 | const {handleSubmit, submitting} = this.props; 19 | 20 | return ( 21 |
22 |
23 |
24 | 26 | 30 | 31 |
32 |
33 | ); 34 | } 35 | } 36 | 37 | export default reduxForm({ 38 | form: 'CommentForm' 39 | })(connect(null, mapDispatchToProps)(CommentForm)) 40 | -------------------------------------------------------------------------------- /src/components/CommentList.css: -------------------------------------------------------------------------------- 1 | .fade-enter { 2 | opacity: 0.01; 3 | } 4 | 5 | .fade-enter-active { 6 | opacity: 1; 7 | transition: opacity 1000ms ease-in; 8 | } 9 | 10 | .fade-exit { 11 | opacity: 1; 12 | } 13 | 14 | .fade-exit-active { 15 | opacity: 0.01; 16 | transition: opacity 1000ms ease-in; 17 | } 18 | -------------------------------------------------------------------------------- /src/components/CommentList.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {Message} from "./Message"; 3 | import timeago from 'timeago.js'; 4 | import {TransitionGroup, CSSTransition} from "react-transition-group"; 5 | 6 | import "./CommentList.css"; 7 | 8 | export class CommentList extends React.Component { 9 | render() { 10 | const {commentList} = this.props; 11 | 12 | if (null === commentList || 0 === commentList.length) { 13 | return (); 14 | } 15 | 16 | return ( 17 |
18 | 19 | {commentList.map(comment => { 20 | return ( 21 | 22 |
23 |

24 | {comment.content} 25 |

26 |

27 | 28 | {timeago().format(comment.published)} by  29 | {comment.author.name} 30 | 31 |

32 |
33 |
34 | ); 35 | })} 36 |
37 |
38 | ) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/components/CommentListContainer.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {commentListFetch, commentListUnload} from "../actions/actions"; 3 | import {connect} from "react-redux"; 4 | import {Spinner} from "./Spinner"; 5 | import {CommentList} from "./CommentList"; 6 | import CommentForm from "./CommentForm"; 7 | import {LoadMore} from "./LoadMore"; 8 | 9 | const mapeStateToProps = state => ({ 10 | ...state.commentList, 11 | isAuthenticated: state.auth.isAuthenticated 12 | }); 13 | 14 | const mapDispatchToProps = { 15 | commentListFetch, 16 | commentListUnload 17 | }; 18 | 19 | class CommentListContainer extends React.Component { 20 | componentDidMount() { 21 | this.props.commentListFetch(this.props.blogPostId); 22 | } 23 | 24 | componentWillUnmount() { 25 | this.props.commentListUnload(); 26 | } 27 | 28 | onLoadMoreClick() { 29 | const {blogPostId, currentPage, commentListFetch} = this.props; 30 | commentListFetch(blogPostId, currentPage); 31 | } 32 | 33 | render() { 34 | const {isFetching, commentList, isAuthenticated, blogPostId, currentPage, pageCount} = this.props; 35 | const showLoadMore = pageCount > 1 && currentPage <= pageCount; 36 | 37 | if (isFetching && currentPage === 1) { 38 | return (); 39 | } 40 | 41 | return ( 42 |
43 | 44 | {showLoadMore && } 47 | {isAuthenticated && } 48 |
49 | ) 50 | } 51 | } 52 | 53 | export default connect(mapeStateToProps, mapDispatchToProps)(CommentListContainer); 54 | -------------------------------------------------------------------------------- /src/components/ConfirmationForm.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {Field, reduxForm} from "redux-form"; 3 | import {renderField} from "../form"; 4 | import {connect} from "react-redux"; 5 | import {userConfirm} from "../actions/actions"; 6 | 7 | const mapDispatchToProps = { 8 | userConfirm 9 | }; 10 | 11 | class ConfirmationForm extends React.Component { 12 | onSubmit(values) { 13 | return this.props.userConfirm(values.confirmationToken) 14 | .then(() => { 15 | this.props.reset(); 16 | }); 17 | } 18 | 19 | render() { 20 | const {handleSubmit, submitting} = this.props; 21 | 22 | return ( 23 |
24 |
25 |

26 | Please confirm your account with token you received in e-mail. 27 |

28 |
29 | 31 | 32 | 36 | 37 |
38 |
39 | ) 40 | } 41 | } 42 | 43 | export default reduxForm({ 44 | form: 'ConfirmationForm' 45 | })(connect(null, mapDispatchToProps)(ConfirmationForm)); 46 | -------------------------------------------------------------------------------- /src/components/Header.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {Link} from "react-router-dom"; 3 | 4 | 5 | export default class Header extends React.Component { 6 | renderUser() { 7 | const {userData, logout} = this.props; 8 | 9 | if (null === userData) { 10 | return (); 11 | } 12 | 13 | return ( 14 | 15 | Hello {userData.name},  16 | 17 | 18 | ); 19 | } 20 | 21 | render() { 22 | const {isAuthenticated} = this.props; 23 | 24 | return ( 25 | 55 | ); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/components/ImageBrowser.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {CSSTransition, TransitionGroup} from "react-transition-group"; 3 | 4 | export class ImageBrowser extends React.Component { 5 | render() { 6 | const {images, deleteHandler, isLocked} = this.props; 7 | 8 | return ( 9 |
10 | 11 | { 12 | images.map(image => { 13 | const onImageDeleteClick = (event) => { 14 | event.preventDefault(); 15 | deleteHandler(image.id); 16 | }; 17 | return ( 18 | 19 |
20 |
21 | 23 |
24 |
25 | 29 |
30 |
31 |
32 | ) 33 | }) 34 | } 35 |
36 |
37 | ) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/components/ImageUpload.css: -------------------------------------------------------------------------------- 1 | .nice-input-upload .form-control-file { 2 | width: 100%; 3 | hegiht: 100%; 4 | visibility: hidden; 5 | cursor: pointer; 6 | position: relative; 7 | min-height: 6em; 8 | } 9 | 10 | .nice-input-upload .form-control-file:before { 11 | content: attr(data-title); 12 | position: absolute; 13 | visibility: visible; 14 | width: 100%; 15 | border: 2px dashed lightgray; 16 | border-radius: 6px; 17 | text-align: center; 18 | overflow: hidden; 19 | color: #6c757d; 20 | font-size: 1em; 21 | min-height: 6em; 22 | line-height: 2em; 23 | padding-top: 2em; 24 | } 25 | -------------------------------------------------------------------------------- /src/components/ImageUpload.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {connect} from "react-redux"; 3 | import "./ImageUpload.css"; 4 | import {imageUpload} from "../actions/actions"; 5 | 6 | const mapDispatchToProps = { 7 | imageUpload 8 | }; 9 | 10 | class ImageUpload extends React.Component { 11 | onChange(e) { 12 | console.log(e.target); 13 | console.log(e.target.files[0]); 14 | const file = e.target.files[0]; 15 | this.props.imageUpload(file); 16 | } 17 | 18 | render() { 19 | return ( 20 |
21 | 26 |
27 | ) 28 | } 29 | } 30 | 31 | export default connect(null, mapDispatchToProps)(ImageUpload); 32 | 33 | 34 | -------------------------------------------------------------------------------- /src/components/LoadMore.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export class LoadMore extends React.Component { 4 | render() { 5 | const {label, disabled, onClick} = this.props; 6 | 7 | return ( 8 | 11 | ) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/components/LoginForm.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {reduxForm, Field} from 'redux-form'; 3 | import {renderField} from "../form"; 4 | import {connect} from 'react-redux'; 5 | import {userLoginAttempt} from "../actions/actions"; 6 | 7 | const mapStateToProps = state => ({ 8 | ...state.auth 9 | }); 10 | 11 | const mapDispatchToProps = { 12 | userLoginAttempt 13 | }; 14 | 15 | class LoginForm extends React.Component { 16 | componentDidUpdate(prevProps) { 17 | if (prevProps.token !== this.props.token) { 18 | console.log(prevProps); 19 | console.log(this.props); 20 | this.props.history.push('/'); 21 | } 22 | } 23 | 24 | onSubmit(values) { 25 | return this.props.userLoginAttempt( 26 | values.username, 27 | values.password 28 | ); 29 | } 30 | 31 | render() { 32 | const {handleSubmit, error} = this.props; 33 | 34 | return ( 35 |
36 | {error &&
{error}
} 37 |
38 | 39 | 40 | 41 | 42 |
43 | ) 44 | } 45 | } 46 | 47 | export default reduxForm({ 48 | form: 'LoginForm' 49 | })(connect(mapStateToProps, mapDispatchToProps)(LoginForm)); 50 | -------------------------------------------------------------------------------- /src/components/Message.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export class Message extends React.Component { 4 | render() { 5 | const {message} = this.props; 6 | 7 | return ( 8 |
9 |
10 |
11 | {message} 12 |
13 |
14 |
15 | ); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/components/Paginator.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import classNames from "classnames"; 3 | 4 | export class Paginator extends React.Component { 5 | constructor(props) { 6 | super(props); 7 | const {pageCount} = this.props; 8 | 9 | this.range = []; 10 | 11 | for (let i = 1; i <= pageCount; i++) { 12 | this.range.push(i); 13 | } 14 | } 15 | 16 | render() { 17 | const {currentPage, setPage, prevPage, nextPage} = this.props; 18 | 19 | return ( 20 | 50 | ) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/components/RegisterForm.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {Field, reduxForm} from "redux-form"; 3 | import {renderField} from "../form"; 4 | import {connect} from "react-redux"; 5 | import {userRegister} from "../actions/actions"; 6 | 7 | const mapDispatchToProps = { 8 | userRegister 9 | }; 10 | 11 | class RegisterForm extends React.Component { 12 | constructor(props) { 13 | super(props); 14 | this.state = {termsAccepted: false}; 15 | } 16 | 17 | onSubmit(values) { 18 | return this.props.userRegister(...Object.values(values)) 19 | .then(() => { 20 | this.props.reset(); 21 | }); 22 | } 23 | 24 | onTermsAcceptedClick(e) { 25 | this.setState(prevState => ({termsAccepted: !prevState.termsAccepted})); 26 | } 27 | 28 | render() { 29 | const {handleSubmit, submitting} = this.props; 30 | 31 | return ( 32 |
33 |
34 |
35 | 36 | 37 | 38 | 39 | 40 | 41 |
42 | 45 | 46 |
47 | 48 | 52 | 53 |
54 |
55 | ) 56 | } 57 | } 58 | 59 | export default reduxForm({ 60 | form: 'RegisterForm' 61 | })(connect(null, mapDispatchToProps)(RegisterForm)); 62 | -------------------------------------------------------------------------------- /src/components/RegistrationContainer.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import RegisterForm from "./RegisterForm"; 3 | import {connect} from "react-redux"; 4 | import ConfirmationForm from "./ConfirmationForm"; 5 | import {userRegisterComplete} from "../actions/actions"; 6 | 7 | const mapStateToProps = state => ({ 8 | ...state.registration 9 | }); 10 | 11 | const mapDispatchToProps = { 12 | userRegisterComplete 13 | }; 14 | 15 | class RegistrationContainer extends React.Component { 16 | constructor(props) { 17 | super(props); 18 | this.state = {counter: 10}; 19 | } 20 | 21 | componentDidUpdate(prevProps, prevState) { 22 | const {confirmationSuccess, history, userRegisterComplete} = this.props; 23 | 24 | if (prevProps.confirmationSuccess !== confirmationSuccess && confirmationSuccess) { 25 | this.timer = setInterval( 26 | () => { 27 | console.log(this.state.counter); 28 | this.setState(prevState => ({counter: prevState.counter - 1})); 29 | }, 30 | 1000 31 | ) 32 | } 33 | 34 | if (prevState.counter !== this.state.counter && this.state.counter <= 0) { 35 | userRegisterComplete(); 36 | history.push('/'); 37 | } 38 | } 39 | 40 | componentWillUnmount() { 41 | this.props.userRegisterComplete(); 42 | 43 | if (this.timer) { 44 | clearInterval(this.timer); 45 | } 46 | } 47 | 48 | render() { 49 | const {registrationSuccess, confirmationSuccess} = this.props; 50 | 51 | if (!registrationSuccess) { 52 | return ; 53 | } 54 | 55 | if (!confirmationSuccess) { 56 | return 57 | } 58 | 59 | return ( 60 |
61 |
62 |

Congratulations!

63 |

64 | You have confirmed your account. You'll be redirected to home page in  65 | {this.state.counter} seconds. 66 |

67 |
68 |
69 | ) 70 | } 71 | } 72 | 73 | export default connect(mapStateToProps, mapDispatchToProps)(RegistrationContainer); 74 | -------------------------------------------------------------------------------- /src/components/Spinner.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export class Spinner extends React.Component { 4 | render() { 5 | return ( 6 |
7 |
8 | 9 |
10 |
11 | ); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/form.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import classNames from "classnames"; 3 | 4 | export const renderField = ({input, label, type, meta: {error}}) => { 5 | const classes = classNames( 6 | 'form-control', 7 | { 8 | 'is-invalid': error 9 | } 10 | ); 11 | return ( 12 |
13 | {label !== null && label !== '' && } 14 | {type !== 'textarea' && } 15 | {type === 'textarea' &&