├── .gitignore ├── README.md ├── firebase.json ├── package-lock.json ├── package.json └── src ├── app ├── components │ ├── Footer.js │ ├── Footer.scss │ ├── GAWrapper.js │ ├── Header.js │ ├── Header.scss │ ├── PageWrapper.js │ ├── auth │ │ ├── CreateAccount.js │ │ ├── CreateAccount.scss │ │ ├── SignIn.js │ │ ├── SignIn.scss │ │ ├── SignInSocial.js │ │ ├── SignInSocial.scss │ │ ├── VerifyEmail.js │ │ └── VerifyEmail.scss │ ├── common │ │ ├── Button.js │ │ ├── Button.scss │ │ ├── Check.js │ │ ├── Check.scss │ │ ├── Modal.js │ │ ├── Modal.scss │ │ ├── TextInput.js │ │ ├── TextInput.scss │ │ ├── global.scss │ │ └── variables.scss │ ├── todo │ │ ├── AddTodo.js │ │ ├── AddTodo.scss │ │ ├── TodoList.js │ │ └── TodoList.scss │ └── user │ │ ├── ChangeName.js │ │ ├── ChangeName.scss │ │ ├── ChangePwd.js │ │ ├── ChangePwd.scss │ │ ├── ImgUpload.js │ │ ├── ImgUpload.scss │ │ ├── SetPwd.js │ │ └── SetPwd.scss ├── lib │ ├── auth │ │ ├── actions.js │ │ └── reducer.js │ ├── firebase.js │ ├── logger.js │ ├── promiseMiddleware.js │ ├── reducer.js │ ├── store.js │ ├── todo │ │ ├── actions.js │ │ └── reducer.js │ └── withAuthentication.js ├── next.config.js ├── pages │ ├── _app.js │ ├── _document.js │ ├── index.js │ ├── index.scss │ ├── user.js │ └── user.scss ├── postcss.config.js └── public │ └── static │ └── img │ ├── firebase.svg │ ├── menu.svg │ ├── mj_white.svg │ ├── nextjs.svg │ ├── react.svg │ └── redux.svg ├── functions ├── .babelrc └── index.js └── public └── favicon.ico /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | node_modules/ 3 | dist/ 4 | .next/ 5 | .firebase/ 6 | .env 7 | .firebaserc 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # :policeman: Notice: Due to long cold-start times of Firebase functions I switched to deploy NextJS apps on Vercel. Therefore this repo is not about to be maintained anymore. 2 | 3 | ### Demo 4 | 5 | ### Boilerplate for React developers who wants to quick start a project in NextJS and Firebase 6 | 7 | 8 | 9 | 10 | Build and deploy this boilerplate and start developing your project without need to build the whole infrastructure from scratch 11 | 12 | Great for front-end developers, shipped with authentication and basic profile management out of the box 13 | 14 | 15 |

16 | ## Features 17 | - **Server Side Rendering** and **code splitting** out of the box 18 | - **Authentication** - email/password, social sign-in providers, basic profile management 19 | - **To-do list example** 20 | 21 | 22 |

23 | ## Stack 24 | - **Firebase** - Build apps fast, without managing infrastructure (database, storage, hosting, server) 25 | - **NextJS** - A minimalistic framework for universal server-rendered React applications 26 | - **Redux** - A predictable state container for JavaScript 27 | 28 | Keeping it minimal so you can choose your tech stack 29 | 30 | Thanks to Firebase and NextJS you can scale up as your product grows 31 | 32 | 33 |

34 | ## Firebase set-up 35 | 1. Create a Firebase project using the [Firebase Console](https://console.firebase.google.com). 36 | 2. Add **web** app to project (don't set up hosting). 37 | 3. Copy Firebase config keys 38 | 4. Create `.env` file in the root dir with following content (replacing with copied values): 39 | ``` 40 | FIREBASE_API_KEY= 41 | FIREBASE_AUTH_DOMAIN= 42 | FIREBASE_DATABASE_URL= 43 | FIREBASE_PROJECT_ID= 44 | FIREBASE_STORAGE_BUCKET= 45 | FIREBASE_MESSAGING_SENDER_ID= 46 | FIREBASE_APP_ID= 47 | GOOGLE_ANALYTICS_ID= 48 | ``` 49 | 5. Create Firebase database in test mode 50 | 6. Create Firebase storage 51 | 7. In auth section set up email/password sign-in method (for more methods see below) 52 | 8. Allow [unauthenticated function invocation](https://cloud.google.com/functions/docs/securing/managing-access-iam#allowing_unauthenticated_function_invocation) 53 | 54 | 55 | 56 |

57 | ## In terminal 58 | 1. Clone or fork this repository. 59 | 1. Install deps `npm install`. 60 | 1. Install Firebase tools `npm install -g firebase-tools` 61 | 1. Login to Firebase `firebase login` 62 | 1. Add Firebase project `firebase use --add` and select your project 63 | 1. Deploy the app `npm run deploy` to Firebase hosting 64 | 1. `npm run dev` to run locally on http://localhost:3000 (Firebase functions must be deployed) 65 | 66 | ### You're all set - now you can focus on actual coding of your project 67 | 68 | 69 |

70 | ## Social platform sign-in providers 71 | You can add support for social platform sign-in providers easily. 72 | 73 | Set it in **Firebase Console** -> **Authentication** -> **Sign-in method** 74 | 75 | - [Set up Google sign-in method](https://firebase.google.com/docs/auth/web/google-signin) 76 | - [Set up Facebook sign-in method](https://firebase.google.com/docs/auth/web/facebook-login) 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 |

90 | ## Security rules 91 | Guard your data with rules that define who has access to it and how it is structured 92 | 93 | ### Database (Firestore) 94 | https://firebase.google.com/docs/firestore/security/get-started 95 | 96 | ### Storage 97 | https://firebase.google.com/docs/storage/security/start 98 | 99 | Use rules below to allow only images up to 3 MB 100 | ``` 101 | service firebase.storage { 102 | match /b/{bucket}/o { 103 | match /{allPaths=**} { 104 | allow read, write: if request.auth != null; 105 | } 106 | 107 | match /images/{imageId} { 108 | // Only allow uploads of any image file that's less than 3MB 109 | allow write: if request.resource.size < 3 * 1024 * 1024 110 | && request.resource.contentType.matches('image/.*'); 111 | } 112 | } 113 | } 114 | ``` 115 | 116 | 117 |

118 | ## More read 119 | - [Firebase](https://firebase.google.com/docs/web/setup) 120 | - [NextJS](https://nextjs.org/learn/basics/getting-started) 121 | 122 | 123 | Nextbase was inspired by [this example](https://github.com/zeit/next.js/tree/canary/examples/with-firebase-hosting) 124 | 125 | 126 | -------------------------------------------------------------------------------- /firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "hosting": { 3 | "public": "dist/public", 4 | "rewrites": [ 5 | { 6 | "source": "**/**", 7 | "function": "next" 8 | } 9 | ] 10 | }, 11 | "functions": { 12 | "source": "dist/functions" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nextbase", 3 | "version": "0.2.0", 4 | "description": "Minimalistic serverless boilerplate based on NextJS and Firebase", 5 | "main": "index.js", 6 | "browserslist": [ 7 | "last 2 versions" 8 | ], 9 | "scripts": { 10 | "dev": "next \"src/app/\"", 11 | "preserve": "npm run build-all && npm run copy-deps && npm run install-deps", 12 | "serve": "cross-env NODE_ENV=production firebase serve", 13 | "predeploy": "npm run build-all && npm run copy-deps", 14 | "deploy": "cross-env NODE_ENV=production firebase deploy", 15 | "clean": "rimraf \"dist/functions/**\" && rimraf \"dist/public\"", 16 | "build-all": "npm run build-public && npm run build-funcs && npm run build-next", 17 | "build-public": "cpx \"src/public/**/*.*\" \"dist/public\" -C", 18 | "build-funcs": "babel \"src/functions\" --out-dir \"dist/functions\"", 19 | "build-next": "next build \"src/app/\"", 20 | "copy-deps": "cpx \"*{package.json,package-lock.json,yarn.lock}\" \"dist/functions\" -C", 21 | "install-deps": "cd \"dist/functions\" && npm i" 22 | }, 23 | "engines": { 24 | "node": "12" 25 | }, 26 | "author": "Martin Juzl", 27 | "license": "ISC", 28 | "dependencies": { 29 | "@zeit/next-sass": "^1.0.1", 30 | "dotenv": "^8.0.0", 31 | "firebase": "^7.14.3", 32 | "firebase-admin": "^8.12.1", 33 | "firebase-functions": "^3.6.1", 34 | "next": "^9.4.0", 35 | "next-images": "^1.1.1", 36 | "next-redux-wrapper": "^6.0.0", 37 | "node-sass": "^4.14.1", 38 | "nodemailer": "^6.2.1", 39 | "normalize.css": "^8.0.1", 40 | "prop-types": "^15.7.2", 41 | "react": "^16.8.6", 42 | "react-dom": "^16.8.6", 43 | "react-flip-move": "^3.0.3", 44 | "react-ga": "^2.5.7", 45 | "react-redux": "^7.2.0", 46 | "react-responsive-modal": "^4.0.1", 47 | "recompose": "^0.30.0", 48 | "redux": "^4.0.5", 49 | "redux-devtools-extension": "^2.13.8", 50 | "redux-thunk": "^2.3.0" 51 | }, 52 | "devDependencies": { 53 | "@babel/cli": "^7.4.4", 54 | "autoprefixer": "^9.5.0", 55 | "cpx": "^1.5.0", 56 | "cross-env": "^5.2.0", 57 | "firebase-tools": "^8.8.1", 58 | "rimraf": "^2.6.0" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/app/components/Footer.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import './Footer.scss' 3 | 4 | const Footer = () => { 5 | return ( 6 | 13 | ) 14 | } 15 | 16 | export default Footer 17 | -------------------------------------------------------------------------------- /src/app/components/Footer.scss: -------------------------------------------------------------------------------- 1 | @import './common/variables'; 2 | 3 | footer { 4 | 5 | position: absolute; 6 | left: 0; 7 | bottom: 0; 8 | width: 100%; 9 | padding: 10px 20px; 10 | 11 | .inner { 12 | display: flex; 13 | justify-content: flex-end; 14 | max-width: 1280px; 15 | margin: 0 auto; 16 | } 17 | 18 | .github { 19 | 20 | cursor: pointer; 21 | 22 | a { 23 | 24 | display: flex; 25 | align-items: center; 26 | color: #666; 27 | text-decoration: none; 28 | 29 | &:hover { 30 | 31 | i { 32 | transform: scale(1.25); 33 | } 34 | 35 | } 36 | 37 | i { 38 | 39 | margin-left: 10px; 40 | font-size: 1.6em; 41 | transition: .3s ease; 42 | 43 | @media (min-width: $tablet) { 44 | font-size: 2em; 45 | } 46 | 47 | } 48 | 49 | } 50 | 51 | } 52 | 53 | } 54 | 55 | -------------------------------------------------------------------------------- /src/app/components/GAWrapper.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import ReactGA from 'react-ga' 3 | import Router from 'next/router' 4 | 5 | const debug = process.env.NODE_ENV !== 'production' 6 | 7 | const GAWrapper = (WrappedComponent) => ( 8 | class GaWrapper extends Component { 9 | 10 | componentDidMount = () => { 11 | if(process.env.NODE_ENV === 'development') return 12 | 13 | this.initGa() 14 | this.trackPageview() 15 | Router.router.events.on('routeChangeComplete', this.trackPageview) 16 | } 17 | 18 | componentWillUnmount = () => { 19 | if(process.env.NODE_ENV === 'development') return 20 | 21 | Router.router.events.off('routeChangeComplete', this.trackPageview) 22 | } 23 | 24 | trackPageview = (path = document.location.pathname) => { 25 | if(path !== this.lastTrackedPath) { 26 | ReactGA.pageview(path) 27 | this.lastTrackedPath = path 28 | } 29 | } 30 | 31 | initGa = () => { 32 | if(!window.GA_INITIALIZED) { 33 | ReactGA.initialize(process.env.GOOGLE_ANALYTICS_ID, { debug }) 34 | window.GA_INITIALIZED = true 35 | } 36 | } 37 | 38 | render = () => { 39 | return ( 40 | 41 | ) 42 | } 43 | 44 | } 45 | ) 46 | 47 | export default GAWrapper 48 | -------------------------------------------------------------------------------- /src/app/components/Header.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import Link from 'next/link' 4 | import { connect } from 'react-redux' 5 | import { bindActionCreators } from 'redux' 6 | import { signOut } from '../lib/auth/actions' 7 | import Modal from './common/Modal' 8 | import CreateAccount from './auth/CreateAccount' 9 | import SignIn from './auth/SignIn' 10 | import './Header.scss' 11 | 12 | class Header extends React.Component { 13 | 14 | static propTypes = { 15 | user: PropTypes.object, 16 | noSignIn: PropTypes.bool 17 | } 18 | 19 | state = { 20 | createAccountVisible: false, 21 | signInVisible: false 22 | } 23 | 24 | signOut = () => { 25 | const { signOut } = this.props 26 | 27 | signOut() 28 | .catch(console.error) 29 | } 30 | 31 | render = () => { 32 | const { user, noSignIn } = this.props 33 | const { createAccountVisible, signInVisible } = this.state 34 | 35 | return ( 36 | <> 37 |
38 |
39 | Nextbase 40 |
41 | 42 | {!user ? 43 | : 52 |
53 | {user.email} 54 | | 55 | Sign out 56 |
57 | } 58 |
59 | 60 | this.setState({createAccountVisible: false})}> 61 | this.setState({createAccountVisible: false})} /> 62 | 63 | 64 | this.setState({signInVisible: false})}> 65 | this.setState({signInVisible: false})} /> 66 | 67 | 68 | ) 69 | } 70 | } 71 | 72 | const mapStateToProps = (state) => ({ 73 | user: state.auth.user 74 | }) 75 | 76 | const mapDispatchToProps = (dispatch) => ( 77 | bindActionCreators({ 78 | signOut 79 | }, dispatch) 80 | ) 81 | 82 | export default connect(mapStateToProps, mapDispatchToProps)(Header) 83 | -------------------------------------------------------------------------------- /src/app/components/Header.scss: -------------------------------------------------------------------------------- 1 | @import './common/variables'; 2 | 3 | header { 4 | 5 | max-width: 1280px; 6 | margin: 0 auto 50px; 7 | padding: 15px 20px; 8 | text-align: center; 9 | 10 | @media (min-width: $tablet) { 11 | display: flex; 12 | justify-content: space-between; 13 | align-items: center; 14 | } 15 | 16 | .logo { 17 | 18 | font-family: 'Nanum Pen Script', cursive; 19 | margin-bottom: 5px; 20 | user-select: none; 21 | 22 | @media (min-width: $tablet) { 23 | margin-bottom: 0; 24 | } 25 | 26 | a { 27 | display: flex; 28 | justify-content: center; 29 | align-items: center; 30 | color: #444; 31 | font-size: 1.8em; 32 | letter-spacing: 1px; 33 | text-decoration: none; 34 | } 35 | 36 | } 37 | 38 | .auth { 39 | 40 | font-size: .95em; 41 | 42 | a { 43 | 44 | color: #444; 45 | cursor: pointer; 46 | opacity: .7; 47 | text-decoration: none; 48 | transition: .1s ease; 49 | 50 | &:hover { 51 | opacity: 1; 52 | } 53 | 54 | } 55 | 56 | .spacer { 57 | margin: 0 10px; 58 | color: #444; 59 | font-weight: 300; 60 | opacity: .5; 61 | } 62 | 63 | } 64 | 65 | } 66 | -------------------------------------------------------------------------------- /src/app/components/PageWrapper.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import GAWrapper from './GAWrapper' 3 | 4 | const PageWrapper = ({ children }) => children 5 | 6 | export default GAWrapper(PageWrapper) 7 | -------------------------------------------------------------------------------- /src/app/components/auth/CreateAccount.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { connect } from 'react-redux' 4 | import { bindActionCreators } from 'redux' 5 | import { createUser, sendVerificationEmail, signInSocial } from '../../lib/auth/actions' 6 | import SignInSocial from './SignInSocial' 7 | import TextInput from '../common/TextInput' 8 | import Button from '../common/Button' 9 | import './CreateAccount.scss' 10 | 11 | class CreateAccount extends React.Component { 12 | 13 | static propTypes = { 14 | createUser: PropTypes.func.isRequired, 15 | signInSocial: PropTypes.func.isRequired, 16 | sendVerificationEmail: PropTypes.func.isRequired, 17 | close: PropTypes.func.isRequired 18 | } 19 | 20 | state = { 21 | email: '', 22 | password: '', 23 | emailError: '', 24 | passwordError: '', 25 | serverError: '', 26 | submitted: false, 27 | loading: false 28 | } 29 | 30 | getValidation = (type, value) => { 31 | switch(type) { 32 | case 'email': 33 | if(!value.length) return 'Email is required' 34 | if(!/^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test(value)) return 'Email is not valid' 35 | return '' 36 | case 'password': 37 | if(value.length === 0) return 'Password is required' 38 | if(value.length < 6) return 'Password must be at least 6 characters long' 39 | return '' 40 | default: 41 | return '' 42 | } 43 | } 44 | 45 | validateInput = (key, value) => { 46 | const error = this.getValidation(key, value) 47 | this.setState({[key+'Error']: error}) 48 | return error 49 | } 50 | 51 | isFormValid = () => { 52 | const { email, password } = this.state 53 | 54 | const emailError = this.validateInput('email', email) 55 | const emailValid = emailError.length === 0 56 | 57 | const pwdError = this.validateInput('password', password) 58 | const pwdValid = pwdError.length === 0 59 | 60 | return (emailValid && pwdValid) 61 | } 62 | 63 | handleSubmit = (e) => { 64 | e.preventDefault() 65 | const { createUser, sendVerificationEmail, close } = this.props 66 | const { email, password } = this.state 67 | 68 | this.setState({submitted: true}) 69 | 70 | if(this.isFormValid()) { 71 | this.setState({loading: true}) 72 | 73 | createUser(email, password) 74 | .then(sendVerificationEmail) 75 | .then(close) 76 | .catch(this.handleError) 77 | } 78 | } 79 | 80 | handleError = (error) => { 81 | this.setState({serverError: error.code, loading: false}) 82 | } 83 | 84 | render = () => { 85 | const { signInSocial, close } = this.props 86 | const { email, password, submitted, emailError, passwordError, serverError, loading } = this.state 87 | 88 | return ( 89 |
90 |
91 | 92 |

Create new account

93 | 94 | this.setState({email: value})} 98 | error={submitted ? emailError : ''} 99 | validate={value => this.validateInput('email', value)} 100 | /> 101 | 102 | this.setState({password: value})} 107 | error={submitted ? passwordError : ''} 108 | validate={value => this.validateInput('password', value)} 109 | /> 110 | 111 | 112 | 113 | {serverError.length > 0 &&
{serverError}
} 114 | 115 | 116 | 117 | 122 |
123 | ) 124 | } 125 | 126 | } 127 | 128 | const mapStateToProps = (state) => ({ 129 | user: state.auth.user 130 | }) 131 | 132 | const mapDispatchToProps = (dispatch) => ( 133 | bindActionCreators({ 134 | createUser, 135 | sendVerificationEmail, 136 | signInSocial 137 | }, dispatch) 138 | ) 139 | 140 | export default connect(mapStateToProps, mapDispatchToProps)(CreateAccount) 141 | -------------------------------------------------------------------------------- /src/app/components/auth/CreateAccount.scss: -------------------------------------------------------------------------------- 1 | @import '../common/variables'; 2 | 3 | .create-account { 4 | 5 | max-width: 420px; 6 | margin: 0 auto; 7 | border-radius: 5px; 8 | border: 1px solid #eee; 9 | background: white; 10 | 11 | form { 12 | 13 | padding: 30px 15px; 14 | 15 | @media (min-width: $tablet) { 16 | padding: 30px; 17 | } 18 | 19 | h1 { 20 | font-family: 'Roboto Slab', serif; 21 | font-size: 1.25em; 22 | font-weight: 500; 23 | margin: 10px 0 30px; 24 | text-align: center; 25 | color: #666; 26 | } 27 | 28 | .server-error { 29 | margin: 20px 0 0; 30 | color: $red; 31 | font-size: .95em; 32 | } 33 | 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /src/app/components/auth/SignIn.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { connect } from 'react-redux' 4 | import { bindActionCreators } from 'redux' 5 | import { signIn, sendPasswordResetEmail, signInSocial } from '../../lib/auth/actions' 6 | import SignInSocial from './SignInSocial' 7 | import TextInput from '../common/TextInput' 8 | import Button from '../common/Button' 9 | import './SignIn.scss' 10 | 11 | class SignIn extends React.Component { 12 | 13 | static propTypes = { 14 | signIn: PropTypes.func.isRequired, 15 | signInSocial: PropTypes.func.isRequired, 16 | close: PropTypes.func 17 | } 18 | 19 | static defaultProps = { 20 | close: () => {} 21 | } 22 | 23 | state = { 24 | email: '', 25 | password: '', 26 | emailError: '', 27 | passwordError: '', 28 | serverError: '', 29 | submitted: false, 30 | loading: false, 31 | forgottenPwd: false 32 | } 33 | 34 | getValidation = (type, value) => { 35 | switch(type) { 36 | case 'email': 37 | if(!value.length) return 'Email is required' 38 | if(!/^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test(value)) return 'Email is not valid' 39 | return '' 40 | case 'password': 41 | if(value.length === 0) return 'Password is required' 42 | return '' 43 | default: 44 | return '' 45 | } 46 | } 47 | 48 | validateInput = (key, value) => { 49 | const error = this.getValidation(key, value) 50 | this.setState({[key+'Error']: error}) 51 | return error 52 | } 53 | 54 | isFormValid = () => { 55 | const { email, password, forgottenPwd } = this.state 56 | 57 | const emailError = this.validateInput('email', email) 58 | const emailValid = emailError.length === 0 59 | 60 | if(forgottenPwd) return emailValid 61 | 62 | const pwdError = this.validateInput('password', password) 63 | const pwdValid = pwdError.length === 0 64 | 65 | return (emailValid && pwdValid) 66 | } 67 | 68 | handleSubmit = (e) => { 69 | e.preventDefault() 70 | const { signIn, sendPasswordResetEmail, close } = this.props 71 | const { email, password, forgottenPwd } = this.state 72 | 73 | this.setState({submitted: true}) 74 | 75 | if(this.isFormValid()) { 76 | this.setState({loading: true}) 77 | 78 | if(forgottenPwd) { 79 | sendPasswordResetEmail(email) 80 | .then(() => this.setState({forgottenPwd: false, serverError: `We've sent password reset form to your inbox`})) 81 | .catch(this.handleError) 82 | } else { 83 | signIn(email, password) 84 | .then(close) 85 | .catch(this.handleError) 86 | } 87 | } 88 | } 89 | 90 | handleError = (error) => { 91 | this.setState({serverError: error.code, loading: false}) 92 | } 93 | 94 | render = () => { 95 | const { signInSocial, close } = this.props 96 | const { email, password, emailError, passwordError, submitted, serverError, forgottenPwd, loading } = this.state 97 | 98 | return ( 99 |
100 |
101 | 102 |

{!forgottenPwd ? 'Sign in' : 'Forgotten password'}

103 | 104 | this.setState({email: value})} 108 | error={submitted ? emailError : ''} 109 | validate={value => this.validateInput('email', value)} 110 | /> 111 | 112 | {!forgottenPwd && ( 113 | this.setState({password: value})} 118 | error={submitted ? passwordError : ''} 119 | validate={value => this.validateInput('password', value)} 120 | /> 121 | )} 122 | 123 | 126 | 127 | {serverError.length > 0 &&
{serverError}
} 128 | 129 | 132 | 133 | 134 | 135 | 140 |
141 | ) 142 | } 143 | 144 | } 145 | 146 | const mapStateToProps = (state) => ({ 147 | user: state.auth.user 148 | }) 149 | 150 | const mapDispatchToProps = (dispatch) => ( 151 | bindActionCreators({ 152 | signIn, 153 | signInSocial, 154 | sendPasswordResetEmail 155 | }, dispatch) 156 | ) 157 | 158 | export default connect(mapStateToProps, mapDispatchToProps)(SignIn) 159 | 160 | -------------------------------------------------------------------------------- /src/app/components/auth/SignIn.scss: -------------------------------------------------------------------------------- 1 | @import '../common/variables'; 2 | 3 | .sign-in { 4 | 5 | max-width: 420px; 6 | margin: 0 auto; 7 | border-radius: 5px; 8 | border: 1px solid #eee; 9 | background: white; 10 | 11 | form { 12 | 13 | padding: 30px 15px; 14 | 15 | @media (min-width: $tablet) { 16 | padding: 30px; 17 | } 18 | 19 | h1 { 20 | font-family: 'Roboto Slab', serif; 21 | font-size: 1.25em; 22 | font-weight: 500; 23 | margin: 10px 0 30px; 24 | text-align: center; 25 | color: #666; 26 | } 27 | 28 | .server-error { 29 | margin: 20px 0 0; 30 | color: $red; 31 | font-size: .95em; 32 | } 33 | 34 | .forgotten { 35 | 36 | margin: 30px 0 15px; 37 | text-align: center; 38 | 39 | a { 40 | 41 | color: #666; 42 | font-size: .95em; 43 | cursor: pointer; 44 | 45 | &:hover { 46 | text-decoration: underline; 47 | } 48 | 49 | } 50 | 51 | } 52 | 53 | } 54 | 55 | } 56 | -------------------------------------------------------------------------------- /src/app/components/auth/SignInSocial.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { firebase } from '../../lib/firebase' 4 | import './SignInSocial.scss' 5 | 6 | class SignInSocial extends React.Component { 7 | 8 | static propTypes = { 9 | signIn: PropTypes.func.isRequired, 10 | onError: PropTypes.func.isRequired, 11 | close: PropTypes.func.isRequired 12 | } 13 | 14 | static defaultProps = { 15 | close: () => {} 16 | } 17 | 18 | getProvider = (type) => { 19 | switch(type) { 20 | case 'google': 21 | const provider = new firebase.auth.GoogleAuthProvider() 22 | provider.addScope('https://www.googleapis.com/auth/userinfo.email') 23 | return provider 24 | 25 | case 'facebook': 26 | return new firebase.auth.FacebookAuthProvider() 27 | 28 | default: 29 | return null 30 | } 31 | } 32 | 33 | handleSignIn = (type) => { 34 | const { signIn, close, onError } = this.props 35 | 36 | const provider = this.getProvider(type) 37 | 38 | signIn(provider) 39 | .then(close) 40 | .catch(onError) 41 | } 42 | 43 | render = () => { 44 | return ( 45 |
46 | 47 |

Sign in using social platforms

48 | 49 | 52 | 53 | 56 | 57 |
58 | ) 59 | } 60 | 61 | } 62 | 63 | export default SignInSocial 64 | -------------------------------------------------------------------------------- /src/app/components/auth/SignInSocial.scss: -------------------------------------------------------------------------------- 1 | @import '../common/variables'; 2 | 3 | .sign-in-social { 4 | 5 | padding: 10px 0 20px; 6 | text-align: center; 7 | color: #888; 8 | background: #eaebf0; 9 | 10 | button { 11 | 12 | margin: 5px; 13 | border: 0; 14 | background: transparent; 15 | font-size: 2em; 16 | transition: .2s ease; 17 | color: #999; 18 | cursor: pointer; 19 | 20 | &:hover { 21 | transform: scale(1.2); 22 | } 23 | 24 | &.fb { 25 | color: #3b5998; 26 | } 27 | 28 | &.google { 29 | color: #ea4335; 30 | } 31 | 32 | } 33 | 34 | } 35 | -------------------------------------------------------------------------------- /src/app/components/auth/VerifyEmail.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { connect } from 'react-redux' 4 | import { bindActionCreators } from 'redux' 5 | import { sendVerificationEmail } from '../../lib/auth/actions' 6 | import './VerifyEmail.scss' 7 | 8 | class VerifyEmail extends React.Component { 9 | 10 | static propTypes = { 11 | sendVerificationEmail: PropTypes.func.isRequired 12 | } 13 | 14 | state = { 15 | sent: false 16 | } 17 | 18 | sendVerificationEmail = () => { 19 | const { sendVerificationEmail } = this.props 20 | 21 | sendVerificationEmail() 22 | .then(() => this.setState({sent: true})) 23 | .catch(console.error) 24 | } 25 | 26 | render = () => { 27 | const { user } = this.props 28 | const { sent } = this.state 29 | 30 | return ( 31 |
32 | 33 |

Verify your email address

34 | 35 |

Please check your mailbox {user.email}. A verification email is waiting for you.

36 | 37 | {!sent ? 38 |

Did not receive the email? Send again

: 39 |

Verification email sent!

40 | } 41 | 42 |
43 | ) 44 | } 45 | 46 | } 47 | 48 | const mapStateToProps = (state) => ({ 49 | user: state.auth.user 50 | }) 51 | 52 | const mapDispatchToProps = (dispatch) => ( 53 | bindActionCreators({ 54 | sendVerificationEmail 55 | }, dispatch) 56 | ) 57 | 58 | export default connect(mapStateToProps, mapDispatchToProps)(VerifyEmail) 59 | 60 | -------------------------------------------------------------------------------- /src/app/components/auth/VerifyEmail.scss: -------------------------------------------------------------------------------- 1 | @import '../common/variables'; 2 | 3 | .verify-email { 4 | 5 | max-width: 420px; 6 | margin: 0 auto; 7 | border-radius: 5px; 8 | border: 1px solid #eee; 9 | background: white; 10 | 11 | @media (min-width: $tablet) { 12 | padding: 30px; 13 | } 14 | 15 | h1 { 16 | font-family: 'Roboto Slab', serif; 17 | font-size: 1.25em; 18 | font-weight: 500; 19 | margin: 10px 0 30px; 20 | text-align: center; 21 | color: #666; 22 | } 23 | 24 | p { 25 | 26 | color: #666; 27 | 28 | b { 29 | color: $green; 30 | font-weight: 600; 31 | } 32 | 33 | a { 34 | 35 | color: $green; 36 | cursor: pointer; 37 | font-weight: 600; 38 | 39 | &:hover { 40 | text-decoration: underline; 41 | } 42 | 43 | } 44 | 45 | } 46 | 47 | .sign-out { 48 | 49 | margin-top: 50px; 50 | text-align: center; 51 | 52 | a { 53 | 54 | color: #666; 55 | font-size: .95em; 56 | cursor: pointer; 57 | 58 | &:hover { 59 | text-decoration: underline; 60 | } 61 | 62 | } 63 | 64 | } 65 | 66 | } 67 | -------------------------------------------------------------------------------- /src/app/components/common/Button.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import './Button.scss' 4 | 5 | const Button = ({ children, loading, onClick }) => ( 6 | 14 | ) 15 | 16 | Button.propTypes = { 17 | children: PropTypes.node.isRequired, 18 | onClick: PropTypes.func, 19 | loading: PropTypes.bool 20 | } 21 | 22 | export default Button 23 | -------------------------------------------------------------------------------- /src/app/components/common/Button.scss: -------------------------------------------------------------------------------- 1 | @import 'variables'; 2 | 3 | .button { 4 | 5 | position: relative; 6 | display: block; 7 | width: 100%; 8 | margin: 0 0 15px; 9 | padding: 10px; 10 | background: #00A0B0; 11 | color: white; 12 | border-radius: 5px; 13 | border: 0; 14 | cursor: pointer; 15 | user-select: none; 16 | transition: all 250ms cubic-bezier(.4,.0,.23,1); 17 | 18 | &:hover { 19 | background: darken(#00A0B0, 8%) 20 | } 21 | 22 | &:active { 23 | background: darken(#00A0B0, 8%) 24 | } 25 | 26 | .loading { 27 | 28 | position: absolute; 29 | right: 10px; 30 | display: inline-block; 31 | width: 20px; 32 | height: 20px; 33 | opacity: 0; 34 | 35 | &.visible { 36 | opacity: 1; 37 | } 38 | 39 | } 40 | 41 | .double-bounce1, .double-bounce2 { 42 | width: 100%; 43 | height: 100%; 44 | border-radius: 50%; 45 | background-color: rgba(white, .8); 46 | opacity: .6; 47 | position: absolute; 48 | top: 0; 49 | left: 0; 50 | animation: sk-bounce 2.0s infinite ease-in-out; 51 | } 52 | 53 | .double-bounce2 { 54 | animation-delay: -1.0s; 55 | } 56 | 57 | @keyframes sk-bounce { 58 | 0%, 100% { transform: scale(0.0); } 59 | 50% { transform: scale(1.0); } 60 | } 61 | 62 | } 63 | -------------------------------------------------------------------------------- /src/app/components/common/Check.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import './Check.scss' 4 | 5 | const Check = ({ checked, onChange }) => ( 6 |
7 | 11 |
12 | ) 13 | 14 | Check.propTypes = { 15 | checked: PropTypes.bool.isRequired, 16 | onChange: PropTypes.func.isRequired 17 | } 18 | 19 | export default Check 20 | -------------------------------------------------------------------------------- /src/app/components/common/Check.scss: -------------------------------------------------------------------------------- 1 | @import 'variables'; 2 | 3 | .check { 4 | 5 | input[type='checkbox'] { 6 | display: none; 7 | } 8 | 9 | label { 10 | position: relative; 11 | display: flex; 12 | align-items: center; 13 | color: #9e9e9e; 14 | transition: color 250ms cubic-bezier(.4,.0,.23,1); 15 | } 16 | 17 | label > span { 18 | 19 | display: flex; 20 | justify-content: center; 21 | align-items: center; 22 | margin-right: 1em; 23 | width: 20px; 24 | height: 20px; 25 | background: transparent; 26 | border: 2px solid #bbb; 27 | border-radius: 2px; 28 | cursor: pointer; 29 | transition: all 250ms cubic-bezier(.4,.0,.23,1); 30 | 31 | &:hover { 32 | border-color: #999; 33 | } 34 | 35 | } 36 | 37 | label:hover { 38 | color: #fff; 39 | } 40 | 41 | label:hover > span { 42 | background: rgba(255,255,255,.1); 43 | } 44 | 45 | input[type='checkbox']:checked + span { 46 | border: 10px solid $blue; 47 | animation: shrink-bounce 200ms cubic-bezier(.4,.0,.23,1); 48 | } 49 | 50 | input[type='checkbox']:checked + span:before { 51 | content: ''; 52 | position: absolute; 53 | top: 8px; 54 | left: 4px; 55 | border-right: 3px solid transparent; 56 | border-bottom: 3px solid transparent; 57 | transform: rotate(45deg); 58 | transform-origin: 0% 100%; 59 | animation: checkbox-check 125ms 250ms cubic-bezier(.4,.0,.23,1) forwards; 60 | } 61 | 62 | @keyframes shrink-bounce { 63 | 0% {transform: scale(1);} 64 | 33% {transform: scale(.85);} 65 | 100% {transform: scale(1);} 66 | } 67 | 68 | @keyframes checkbox-check { 69 | 0% { 70 | width: 0; 71 | height: 0; 72 | border-color: white; 73 | transform: translate3d(0,0,0) rotate(45deg); 74 | } 75 | 33% { 76 | width: .2em; 77 | height: 0; 78 | transform: translate3d(0,0,0) rotate(45deg); 79 | } 80 | 100% { 81 | width: .2em; 82 | height: .5em; 83 | border-color: white; 84 | transform: translate3d(0,-.5em,0) rotate(45deg); 85 | } 86 | } 87 | 88 | } 89 | -------------------------------------------------------------------------------- /src/app/components/common/Modal.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import ResponsiveModal from 'react-responsive-modal' 4 | import './Modal.scss' 5 | 6 | const Modal = (props) => { 7 | let className = 'modal' 8 | if(props.noPadding) className += ' no-padding' 9 | if(props.menu) className += ' menu' 10 | 11 | return ( 12 | {}} 15 | classNames={{ 16 | modal: className, 17 | overlay: props.menu ? 'modal-overlay menu' : 'modal-overlay', 18 | closeButton: props.whiteClose ? 'modal-close white' : 'modal-close' 19 | }} 20 | showCloseIcon={!props.noClose} 21 | center 22 | > 23 | {props.children} 24 | 25 | ) 26 | } 27 | 28 | Modal.propTypes = { 29 | visible: PropTypes.bool.isRequired, 30 | children: PropTypes.node.isRequired, 31 | onClose: PropTypes.func, 32 | noPadding: PropTypes.bool, 33 | noClose: PropTypes.bool, 34 | menu: PropTypes.bool, 35 | whiteClose: PropTypes.bool 36 | } 37 | 38 | export default Modal 39 | -------------------------------------------------------------------------------- /src/app/components/common/Modal.scss: -------------------------------------------------------------------------------- 1 | @import 'variables'; 2 | 3 | :global { 4 | 5 | .modal { 6 | 7 | width: 100%; 8 | max-width: 420px; 9 | padding: 10px; 10 | border-radius: 3px; 11 | overflow: hidden; 12 | 13 | @media (min-width: $tablet) { 14 | padding: 15px; 15 | } 16 | 17 | &.no-padding { 18 | padding: 0; 19 | } 20 | 21 | &.menu { 22 | 23 | margin: 0 0 0 auto; 24 | 25 | @media (min-width: 420px) { 26 | margin: 3px 7px 3px auto; 27 | } 28 | 29 | } 30 | 31 | } 32 | 33 | .modal-overlay { 34 | 35 | background: rgba(#222, .9); 36 | padding: 5px; 37 | z-index: 9999; 38 | 39 | &.menu { 40 | align-items: flex-start; 41 | } 42 | 43 | } 44 | 45 | .modal-close { 46 | 47 | cursor: pointer; 48 | top: 4px; 49 | right: 4px; 50 | padding: 8px; 51 | 52 | svg path { 53 | fill: #ccc; 54 | transition: .1s ease; 55 | } 56 | 57 | &:hover svg path { 58 | fill: #aaa; 59 | } 60 | 61 | &.white { 62 | 63 | svg path { 64 | fill: #fff; 65 | } 66 | 67 | &:hover svg path { 68 | fill: #000; 69 | } 70 | 71 | } 72 | 73 | } 74 | 75 | } 76 | -------------------------------------------------------------------------------- /src/app/components/common/TextInput.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import PropTypes from 'prop-types' 3 | import './TextInput.scss' 4 | 5 | class TextInput extends Component { 6 | 7 | static propTypes = { 8 | placeholder: PropTypes.string.isRequired, 9 | value: PropTypes.string.isRequired, 10 | onChange: PropTypes.func.isRequired, 11 | type: PropTypes.string, 12 | disabled: PropTypes.bool, 13 | error: PropTypes.string, 14 | validate: PropTypes.func 15 | } 16 | 17 | state = { 18 | pristine: true 19 | } 20 | 21 | handleChange = (value) => { 22 | const { onChange, disabled, validate } = this.props 23 | const { pristine } = this.state 24 | 25 | if(disabled) return false 26 | if(pristine) this.setState({pristine: false}) 27 | 28 | onChange(value) 29 | 30 | if(validate) validate(value) 31 | } 32 | 33 | handleBlur = () => { 34 | const { value, validate } = this.props 35 | const { pristine } = this.state 36 | 37 | if(validate && !pristine) validate(value) 38 | } 39 | 40 | render = () => { 41 | const { value, type, placeholder, disabled, error } = this.props 42 | 43 | return ( 44 |
45 | 46 | this.handleChange(e.target.value)} 50 | placeholder={placeholder} 51 | disabled={!!disabled} 52 | onBlur={this.handleBlur} 53 | /> 54 | 55 | {error &&
{error}
} 56 | 57 |
58 | ) 59 | } 60 | 61 | } 62 | 63 | export default TextInput 64 | -------------------------------------------------------------------------------- /src/app/components/common/TextInput.scss: -------------------------------------------------------------------------------- 1 | @import './variables'; 2 | 3 | .text-input { 4 | 5 | margin: 0 0 15px; 6 | 7 | input { 8 | 9 | display: block; 10 | width: 100%; 11 | padding: 15px; 12 | border: 0; 13 | background: #eaebf0; 14 | color: #515257; 15 | line-height: 20px; 16 | border-radius: 5px; 17 | 18 | &::placeholder { 19 | color: #888; 20 | } 21 | 22 | } 23 | 24 | .error { 25 | color: indianred; 26 | padding: 5px; 27 | font-size: .95em; 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /src/app/components/common/global.scss: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css?family=Muli:300,400,600,700|Nanum+Pen+Script|Roboto+Slab:300,400,700&subset=latin-ext'); 2 | @import url('https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css'); 3 | @import '~normalize.css/normalize.css'; 4 | @import './variables'; 5 | 6 | html, body { 7 | margin: 0; 8 | padding: 0; 9 | height: 100%; 10 | font-family: 'Muli', sans-serif; 11 | background: #f5f5f5; 12 | } 13 | 14 | * { 15 | box-sizing: border-box; 16 | outline: none; 17 | } 18 | -------------------------------------------------------------------------------- /src/app/components/common/variables.scss: -------------------------------------------------------------------------------- 1 | $green: #4d9d8f; 2 | $orange: #f0b82e; 3 | $red: indianred; 4 | //$blue: #1e88e5; 5 | $blue: #00A0B0; 6 | 7 | $tablet: 800px; 8 | $desktop: 1080px; 9 | 10 | $facebook: #3b5998; 11 | $google: #ea4335; 12 | -------------------------------------------------------------------------------- /src/app/components/todo/AddTodo.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import PropTypes from 'prop-types' 3 | import { connect } from 'react-redux' 4 | import { bindActionCreators } from 'redux' 5 | import { getTodo, createTodo } from '../../lib/todo/actions' 6 | import { firebase } from '../../lib/firebase' 7 | import Button from '../common/Button' 8 | import TextInput from '../common/TextInput' 9 | import './AddTodo.scss' 10 | 11 | class AddTodo extends Component { 12 | 13 | static propTypes = { 14 | createTodo: PropTypes.func.isRequired, 15 | getTodo: PropTypes.func.isRequired 16 | } 17 | 18 | state = { 19 | value: '', 20 | loading: false 21 | } 22 | 23 | handleSubmit = (e) => { 24 | e.preventDefault() 25 | 26 | const { createTodo, getTodo } = this.props 27 | const { value, loading } = this.state 28 | 29 | if(value.length === 0 || loading) return 30 | 31 | this.setState({loading: true}) 32 | 33 | const todo = { 34 | todo: value, 35 | checked: false, 36 | createdAt: firebase.firestore.Timestamp.fromDate(new Date()) 37 | } 38 | 39 | createTodo(todo) 40 | .then(ref => getTodo(ref.id)) 41 | .then(() => this.setState({value: '', loading: false})) 42 | .catch(console.error) 43 | } 44 | 45 | render = () => { 46 | const { value, loading } = this.state 47 | 48 | return ( 49 |
50 | this.setState({value})} 53 | placeholder="Go get some milk" 54 | /> 55 | 56 | 57 | 58 | ) 59 | } 60 | 61 | } 62 | 63 | const mapStateToProps = (state) => ({}) 64 | 65 | const mapDispatchToProps = (dispatch) => ( 66 | bindActionCreators({ 67 | getTodo, 68 | createTodo 69 | }, dispatch) 70 | ) 71 | 72 | export default connect(mapStateToProps, mapDispatchToProps)(AddTodo) 73 | -------------------------------------------------------------------------------- /src/app/components/todo/AddTodo.scss: -------------------------------------------------------------------------------- 1 | @import '../common/variables'; 2 | 3 | .add-todo { 4 | max-width: 420px; 5 | margin: 0 auto 15px; 6 | padding: 0 10px; 7 | } 8 | -------------------------------------------------------------------------------- /src/app/components/todo/TodoList.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import PropTypes from 'prop-types' 3 | import { connect } from 'react-redux' 4 | import { bindActionCreators } from 'redux' 5 | import { getTodo, updateTodo, deleteTodo, todoDeleted } from '../../lib/todo/actions' 6 | import FlipMove from 'react-flip-move' 7 | import Check from '../common/Check' 8 | import './TodoList.scss' 9 | 10 | class TodoList extends Component { 11 | 12 | static propTypes = { 13 | getTodo: PropTypes.func.isRequired, 14 | updateTodo: PropTypes.func.isRequired, 15 | deleteTodo: PropTypes.func.isRequired, 16 | todoDeleted: PropTypes.func.isRequired, 17 | todos: PropTypes.arrayOf(PropTypes.object).isRequired 18 | } 19 | 20 | state = { 21 | hideCompleted: false, 22 | editingId: null, 23 | editingText: '' 24 | } 25 | 26 | toggleTodo = (todo) => { 27 | const { updateTodo, getTodo } = this.props 28 | 29 | updateTodo(todo.id, {checked: !todo.checked}) 30 | .then(() => getTodo(todo.id)) 31 | .catch(console.error) 32 | } 33 | 34 | deleteTodo = (todoId) => { 35 | const { deleteTodo, todoDeleted } = this.props 36 | 37 | deleteTodo(todoId) 38 | .then(() => todoDeleted(todoId)) 39 | .catch(console.error) 40 | } 41 | 42 | handleClick = (todo) => { 43 | this.setState({editingId: todo.id, editingText: todo.todo}, () => { 44 | if(this.input) this.input.focus() 45 | }) 46 | } 47 | 48 | handleBlur = (e) => { 49 | const { todos, updateTodo, getTodo } = this.props 50 | const { editingId, editingText } = this.state 51 | 52 | const todo = todos.find(todo => todo.id === editingId) 53 | 54 | if(!todo) return 55 | 56 | if(todo.todo !== editingText) { 57 | updateTodo(todo.id, {todo: editingText}) 58 | .then(() => getTodo(todo.id)) 59 | .then(() => this.setState({editingId: null})) 60 | .catch(console.error) 61 | } else { 62 | this.setState({editingId: null}) 63 | } 64 | } 65 | 66 | handleKeyUp = (e) => { 67 | if(this.input && e.key === 'Enter') this.input.blur() 68 | } 69 | 70 | render = () => { 71 | const { todos } = this.props 72 | const { hideCompleted, editingId, editingText } = this.state 73 | 74 | return ( 75 |
76 | 80 | 81 | 82 | {todos.filter(todo => hideCompleted ? !todo.checked : true).map(todo => ( 83 |
84 | this.toggleTodo(todo)} 87 | /> 88 | 89 | {editingId === todo.id ? 90 | this.setState({editingText: e.target.value})} 95 | onBlur={this.handleBlur} 96 | onKeyUp={this.handleKeyUp} 97 | ref={elem => this.input = elem} 98 | /> : 99 |
this.handleClick(todo)}>{todo.todo}
100 | } 101 | 102 | 105 |
106 | ))} 107 |
108 |
109 | ) 110 | } 111 | 112 | } 113 | 114 | const mapStateToProps = (state) => ({ 115 | todos: state.todo.todos 116 | }) 117 | 118 | const mapDispatchToProps = (dispatch) => ( 119 | bindActionCreators({ 120 | getTodo, 121 | updateTodo, 122 | deleteTodo, 123 | todoDeleted 124 | }, dispatch) 125 | ) 126 | 127 | export default connect(mapStateToProps, mapDispatchToProps)(TodoList) 128 | -------------------------------------------------------------------------------- /src/app/components/todo/TodoList.scss: -------------------------------------------------------------------------------- 1 | @import '../common/variables'; 2 | 3 | .todo-list { 4 | 5 | max-width: 420px; 6 | margin: 0 auto; 7 | 8 | .hide-completed { 9 | display: flex; 10 | align-items: center; 11 | justify-content: center; 12 | font-size: .95em; 13 | color: #999; 14 | padding: 15px; 15 | cursor: pointer; 16 | user-select: none; 17 | } 18 | 19 | .todo { 20 | 21 | display: flex; 22 | min-height: 50px; 23 | align-items: center; 24 | margin: 10px; 25 | padding: 10px 10px 10px 20px; 26 | background: white; 27 | border-radius: 20px; 28 | 29 | .text { 30 | flex: 1; 31 | color: #666; 32 | padding: 5px; 33 | } 34 | 35 | input.text { 36 | border: none; 37 | border-radius: 2px; 38 | background: #f5f5f5; 39 | } 40 | 41 | @media (min-width: $desktop) { 42 | 43 | &:hover { 44 | 45 | .delete { 46 | opacity: 1; 47 | } 48 | 49 | } 50 | 51 | } 52 | 53 | .delete { 54 | 55 | padding: 4px 10px; 56 | background: transparent; 57 | border: 0; 58 | font-size: 1.2em; 59 | color: #bbb; 60 | cursor: pointer; 61 | transition: all 250ms cubic-bezier(.4,.0,.23,1); 62 | 63 | @media (min-width: $desktop) { 64 | opacity: 0; 65 | } 66 | 67 | &:hover { 68 | color: #999; 69 | } 70 | 71 | } 72 | 73 | } 74 | 75 | } 76 | -------------------------------------------------------------------------------- /src/app/components/user/ChangeName.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import PropTypes from 'prop-types' 3 | import { connect } from 'react-redux' 4 | import { bindActionCreators } from 'redux' 5 | import { changePassword, updateUser, setUser } from '../../lib/auth/actions' 6 | import { auth } from '../../lib/firebase' 7 | import TextInput from '../common/TextInput' 8 | import Button from '../common/Button' 9 | import './ChangeName.scss' 10 | 11 | class ChangeName extends Component { 12 | 13 | static propTypes = { 14 | changePassword: PropTypes.func.isRequired, 15 | updateUser: PropTypes.func.isRequired, 16 | setUser: PropTypes.func.isRequired, 17 | close: PropTypes.func.isRequired, 18 | user: PropTypes.object 19 | } 20 | 21 | state = { 22 | name: '', 23 | nameError: '', 24 | serverError: '', 25 | submitted: false, 26 | loading: false 27 | } 28 | 29 | componentDidMount = () => { 30 | const { user } = this.props 31 | 32 | this.setState({name: user.displayName || ''}) 33 | } 34 | 35 | getValidation = (type, value) => { 36 | switch(type) { 37 | case 'name': 38 | if(!value.length) return 'Name is required' 39 | if(value.length < 3) return 'Password should be at least 3 characters long' 40 | return '' 41 | default: 42 | return '' 43 | } 44 | } 45 | 46 | validateInput = (key, value) => { 47 | const error = this.getValidation(key, value) 48 | this.setState({[key+'Error']: error}) 49 | return error 50 | } 51 | 52 | isFormValid = () => { 53 | const { name } = this.state 54 | 55 | const nameError = this.validateInput('name', name) 56 | const nameValid = nameError.length === 0 57 | 58 | return nameValid 59 | } 60 | 61 | handleSubmit = (e) => { 62 | e.preventDefault() 63 | const { updateUser, setUser, close } = this.props 64 | const { name, loading } = this.state 65 | 66 | this.setState({submitted: true}) 67 | 68 | if(!loading && this.isFormValid()) { 69 | this.setState({loading: true}) 70 | 71 | updateUser({displayName: name}) 72 | .then(() => setUser(auth.currentUser)) 73 | .then(close) 74 | .catch(error => this.setState({serverError: error.code, loading: false})) 75 | } 76 | } 77 | 78 | render = () => { 79 | const { name, nameError, serverError, submitted, loading } = this.state 80 | 81 | return ( 82 |
83 | 84 |

Change name

85 | 86 | this.setState({name: value})} 90 | error={submitted ? nameError : ''} 91 | validate={value => this.validateInput('name', value)} 92 | /> 93 | 94 | 95 | 96 | {serverError.length > 0 &&
{serverError}
} 97 | 98 | 99 | ) 100 | } 101 | } 102 | 103 | const mapStateToProps = (state) => ({ 104 | user: state.auth.user 105 | }) 106 | 107 | const mapDispatchToProps = (dispatch) => ( 108 | bindActionCreators({ 109 | changePassword, 110 | updateUser, 111 | setUser 112 | }, dispatch) 113 | ) 114 | 115 | export default connect(mapStateToProps, mapDispatchToProps)(ChangeName) 116 | 117 | -------------------------------------------------------------------------------- /src/app/components/user/ChangeName.scss: -------------------------------------------------------------------------------- 1 | @import '../common/variables'; 2 | 3 | .change-name { 4 | 5 | padding: 30px 15px; 6 | 7 | @media (min-width: $tablet) { 8 | padding: 30px; 9 | } 10 | 11 | h1 { 12 | font-family: 'Roboto Slab', serif; 13 | font-size: 1.25em; 14 | font-weight: 500; 15 | margin: 10px 0 30px; 16 | text-align: center; 17 | color: #666; 18 | } 19 | 20 | button { 21 | margin: 30px 0 0; 22 | } 23 | 24 | .server-error { 25 | margin: 20px 0 0; 26 | color: $red; 27 | font-size: .95em; 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /src/app/components/user/ChangePwd.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import PropTypes from 'prop-types' 3 | import { connect } from 'react-redux' 4 | import { bindActionCreators } from 'redux' 5 | import { changePassword, reauthenticate } from '../../lib/auth/actions' 6 | import TextInput from '../common/TextInput' 7 | import Button from '../common/Button' 8 | import './ChangePwd.scss' 9 | 10 | class ChangePwd extends Component { 11 | 12 | static propTypes = { 13 | changePassword: PropTypes.func.isRequired, 14 | reauthenticate: PropTypes.func.isRequired, 15 | close: PropTypes.func.isRequired, 16 | user: PropTypes.object 17 | } 18 | 19 | state = { 20 | oldPwd: '', 21 | newPwd: '', 22 | oldPwdError: '', 23 | newPwdError: '', 24 | serverError: '', 25 | submitted: false, 26 | loading: false 27 | } 28 | 29 | getValidation = (type, value) => { 30 | switch(type) { 31 | case 'oldPwd': 32 | if(!value.length) return 'Password is required' 33 | if(value.length < 6) return 'Password should be at least 6 characters long' 34 | return '' 35 | case 'newPwd': 36 | if(!value.length) return 'Password is required' 37 | if(value.length < 6) return 'Password should be at least 6 characters long' 38 | return '' 39 | default: 40 | return '' 41 | } 42 | } 43 | 44 | validateInput = (key, value) => { 45 | const error = this.getValidation(key, value) 46 | this.setState({[key+'Error']: error}) 47 | return error 48 | } 49 | 50 | isFormValid = () => { 51 | const { oldPwd, newPwd } = this.state 52 | 53 | const oldPwdError = this.validateInput('oldPwd', oldPwd) 54 | const oldPwdValid = oldPwdError.length === 0 55 | 56 | const newPwdError = this.validateInput('newPwd', newPwd) 57 | const newPwdValid = newPwdError.length === 0 58 | 59 | return (oldPwdValid && newPwdValid) 60 | } 61 | 62 | handleSubmit = (e) => { 63 | e.preventDefault() 64 | const { user, changePassword, reauthenticate, close } = this.props 65 | const { oldPwd, newPwd, loading } = this.state 66 | 67 | this.setState({submitted: true}) 68 | 69 | if(!loading && this.isFormValid()) { 70 | this.setState({loading: true}) 71 | 72 | reauthenticate(user.email, oldPwd) 73 | .then(() => changePassword(newPwd)) 74 | .then(close) 75 | .catch(error => this.setState({serverError: error.code, loading: false})) 76 | } 77 | } 78 | 79 | render = () => { 80 | const { oldPwd, newPwd, oldPwdError, newPwdError, submitted, serverError, loading } = this.state 81 | 82 | return ( 83 |
84 | 85 |

Change password

86 | 87 | this.setState({oldPwd: value})} 92 | error={submitted ? oldPwdError : ''} 93 | validate={value => this.validateInput('oldPwd', value)} 94 | /> 95 | 96 | this.setState({newPwd: value})} 101 | error={submitted ? newPwdError : ''} 102 | validate={value => this.validateInput('newPwd', value)} 103 | /> 104 | 105 | 106 | 107 | {serverError.length > 0 &&
{serverError}
} 108 | 109 | 110 | ) 111 | } 112 | } 113 | 114 | const mapStateToProps = (state) => ({ 115 | user: state.auth.user 116 | }) 117 | 118 | const mapDispatchToProps = (dispatch) => ( 119 | bindActionCreators({ 120 | changePassword, 121 | reauthenticate 122 | }, dispatch) 123 | ) 124 | 125 | export default connect(mapStateToProps, mapDispatchToProps)(ChangePwd) 126 | 127 | -------------------------------------------------------------------------------- /src/app/components/user/ChangePwd.scss: -------------------------------------------------------------------------------- 1 | @import '../common/variables'; 2 | 3 | .change-pwd { 4 | 5 | padding: 30px 15px; 6 | 7 | @media (min-width: $tablet) { 8 | padding: 30px; 9 | } 10 | 11 | h1 { 12 | font-family: 'Roboto Slab', serif; 13 | font-size: 1.25em; 14 | font-weight: 500; 15 | margin: 10px 0 30px; 16 | text-align: center; 17 | color: #666; 18 | } 19 | 20 | button { 21 | margin: 30px 0 0; 22 | } 23 | 24 | .server-error { 25 | margin: 20px 0 0; 26 | color: $red; 27 | font-size: .95em; 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /src/app/components/user/ImgUpload.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import PropTypes from 'prop-types' 3 | import { connect } from 'react-redux' 4 | import { bindActionCreators } from 'redux' 5 | import { uploadFile, updateUser, setUser } from '../../lib/auth/actions' 6 | import { auth } from '../../lib/firebase' 7 | import Button from '../common/Button' 8 | import './ImgUpload.scss' 9 | 10 | class ImgUpload extends Component { 11 | 12 | static propTypes = { 13 | uploadFile: PropTypes.func.isRequired, 14 | updateUser: PropTypes.func.isRequired, 15 | setUser: PropTypes.func.isRequired, 16 | close: PropTypes.func.isRequired, 17 | user: PropTypes.object 18 | } 19 | 20 | state = { 21 | serverError: '', 22 | loading: false 23 | } 24 | 25 | handleFileChange = () => { 26 | const filename = (this.file && this.file.files.length) ? this.file.files[0].name : '' 27 | this.setState({filename}) 28 | } 29 | 30 | uploadFile = (e) => { 31 | e.preventDefault() 32 | const { user, uploadFile, updateUser, setUser, close } = this.props 33 | const file = this.file.files[0] 34 | 35 | if(!file) return 36 | 37 | this.setState({loading: true}) 38 | 39 | const ext = file.name.substring(file.name.lastIndexOf('.') + 1).toLowerCase() 40 | const time = new Date().getTime() 41 | const name = `${user.uid}.${ext}` 42 | const path = `images/${name}` 43 | 44 | uploadFile(file, name, path) 45 | .then(snapshot => snapshot.ref.getDownloadURL()) 46 | .then(downloadURL => updateUser({photoURL: downloadURL})) 47 | .then(() => setUser(auth.currentUser)) 48 | .then(close) 49 | .catch(error => this.setState({serverError: error.code, loading: false})) 50 | } 51 | 52 | render = () => { 53 | const { serverError, loading } = this.state 54 | 55 | return ( 56 |
57 | 58 |

Change profile picture

59 | 60 | this.file = elem} type="file" onChange={this.handleFileChange}/> 61 | 62 | 63 | 64 | {serverError.length > 0 &&
{serverError}
} 65 | 66 |
67 | ) 68 | } 69 | } 70 | 71 | const mapStateToProps = (state) => ({ 72 | user: state.auth.user 73 | }) 74 | 75 | const mapDispatchToProps = (dispatch) => ( 76 | bindActionCreators({ 77 | uploadFile, 78 | updateUser, 79 | setUser 80 | }, dispatch) 81 | ) 82 | 83 | export default connect(mapStateToProps, mapDispatchToProps)(ImgUpload) 84 | 85 | -------------------------------------------------------------------------------- /src/app/components/user/ImgUpload.scss: -------------------------------------------------------------------------------- 1 | @import '../common/variables'; 2 | 3 | .img-upload { 4 | 5 | padding: 30px 15px; 6 | 7 | @media (min-width: $tablet) { 8 | padding: 30px; 9 | } 10 | 11 | h1 { 12 | font-family: 'Roboto Slab', serif; 13 | font-size: 1.25em; 14 | font-weight: 500; 15 | margin: 10px 0 30px; 16 | text-align: center; 17 | color: #666; 18 | } 19 | 20 | button { 21 | margin: 30px 0 0; 22 | } 23 | 24 | .server-error { 25 | margin: 20px 0 0; 26 | color: $red; 27 | font-size: .95em; 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /src/app/components/user/SetPwd.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import PropTypes from 'prop-types' 3 | import { connect } from 'react-redux' 4 | import { bindActionCreators } from 'redux' 5 | import { setPassword, setUser } from '../../lib/auth/actions' 6 | import TextInput from '../common/TextInput' 7 | import Button from '../common/Button' 8 | import './SetPwd.scss' 9 | 10 | class SetPwd extends Component { 11 | 12 | static propTypes = { 13 | setPassword: PropTypes.func.isRequired, 14 | setUser: PropTypes.func.isRequired, 15 | close: PropTypes.func.isRequired, 16 | user: PropTypes.object 17 | } 18 | 19 | state = { 20 | pwd: '', 21 | pwdAgain: '', 22 | pwdError: '', 23 | pwdAgainError: '', 24 | serverError: '', 25 | submitted: false, 26 | loading: false 27 | } 28 | 29 | getValidation = (type, value) => { 30 | switch(type) { 31 | case 'pwd': 32 | if(!value.length) return 'Password is required' 33 | if(value.length < 6) return 'Password should be at least 6 characters long' 34 | return '' 35 | case 'pwdAgain': 36 | if(!value.length) return 'Password is required' 37 | if(value !== this.state.pwd) return 'Passwords don\'t match' 38 | return '' 39 | default: 40 | return '' 41 | } 42 | } 43 | 44 | validateInput = (key, value) => { 45 | const error = this.getValidation(key, value) 46 | this.setState({[key+'Error']: error}) 47 | return error 48 | } 49 | 50 | isFormValid = () => { 51 | const { pwd, pwdAgain } = this.state 52 | 53 | const pwdError = this.validateInput('pwd', pwd) 54 | const pwdValid = pwdError.length === 0 55 | 56 | const pwdAgainError = this.validateInput('pwdAgain', pwdAgain) 57 | const pwdAgainValid = pwdAgainError.length === 0 58 | 59 | return (pwdValid && pwdAgainValid) 60 | } 61 | 62 | handleSubmit = (e) => { 63 | e.preventDefault() 64 | const { user, setPassword, setUser, close } = this.props 65 | const { pwd, loading } = this.state 66 | 67 | this.setState({submitted: true}) 68 | 69 | if(!loading && this.isFormValid()) { 70 | this.setState({loading: true}) 71 | 72 | setPassword(user.email, pwd) 73 | .then(({user}) => setUser(user)) 74 | .then(close) 75 | .catch(error => this.setState({serverError: error.code, loading: false})) 76 | } 77 | } 78 | 79 | render = () => { 80 | const { user } = this.props 81 | const { submitted, loading, pwd, pwdError, pwdAgain, pwdAgainError, serverError } = this.state 82 | 83 | return ( 84 |
85 | 86 |

Set password

87 | 88 | {}} 92 | disabled 93 | /> 94 | 95 | this.setState({pwd: value})} 100 | error={(submitted || pwd.length) > 0 ? pwdError : ''} 101 | validate={value => this.validateInput('pwd', value)} 102 | /> 103 | 104 | this.setState({pwdAgain: value})} 109 | error={(submitted || pwdAgain.length > 0) ? pwdAgainError : ''} 110 | validate={value => this.validateInput('pwdAgain', value)} 111 | /> 112 | 113 | 114 | 115 | {serverError.length > 0 &&
{serverError}
} 116 | 117 | 118 | ) 119 | } 120 | } 121 | 122 | const mapStateToProps = (state) => ({ 123 | user: state.auth.user 124 | }) 125 | 126 | const mapDispatchToProps = (dispatch) => ( 127 | bindActionCreators({ 128 | setPassword, 129 | setUser 130 | }, dispatch) 131 | ) 132 | 133 | export default connect(mapStateToProps, mapDispatchToProps)(SetPwd) 134 | 135 | -------------------------------------------------------------------------------- /src/app/components/user/SetPwd.scss: -------------------------------------------------------------------------------- 1 | @import '../common/variables'; 2 | 3 | .set-pwd { 4 | 5 | padding: 30px 15px; 6 | 7 | @media (min-width: $tablet) { 8 | padding: 30px; 9 | } 10 | 11 | h1 { 12 | font-family: 'Roboto Slab', serif; 13 | font-size: 1.25em; 14 | font-weight: 500; 15 | margin: 10px 0 30px; 16 | text-align: center; 17 | color: #666; 18 | } 19 | 20 | button { 21 | margin: 30px 0 0; 22 | } 23 | 24 | .server-error { 25 | margin: 20px 0 0; 26 | color: $red; 27 | font-size: .95em; 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /src/app/lib/auth/actions.js: -------------------------------------------------------------------------------- 1 | import { auth, storage, firebase } from '../firebase' 2 | 3 | export const setUser = (user) => ({ type: 'SET_USER', user }) 4 | 5 | export const createUser = (email, password) => ({ 6 | type: 'CREATE_USER', 7 | payload: auth.createUserWithEmailAndPassword(email, password) 8 | }) 9 | 10 | export const signIn = (email, password) => ({ 11 | type: 'SIGN_IN', 12 | payload: auth.signInWithEmailAndPassword(email, password) 13 | }) 14 | 15 | export const signInSocial = (provider) => ({ 16 | type: 'SIGN_IN_SOCIAL', 17 | payload: auth.signInWithPopup(provider) 18 | }) 19 | 20 | export const signOut = () => ({ 21 | type: 'SIGN_OUT', 22 | payload: auth.signOut() 23 | }) 24 | 25 | export const sendPasswordResetEmail = (email) => ({ 26 | type: 'SEND_PASSWORD_RESET_EMAIL', 27 | payload: auth.sendPasswordResetEmail(email) 28 | }) 29 | 30 | export const sendVerificationEmail = () => ({ 31 | type: 'SEND_VERIFICATION_EMAIL', 32 | payload: auth.currentUser.sendEmailVerification() 33 | }) 34 | 35 | export const setPassword = (email, password) => ({ 36 | type: 'SET_PASSWORD', 37 | payload: auth.currentUser.linkAndRetrieveDataWithCredential(firebase.auth.EmailAuthProvider.credential(email, password)) 38 | }) 39 | 40 | export const changePassword = (newPassword) => ({ 41 | type: 'CHANGE_PASSWORD', 42 | payload: auth.currentUser.updatePassword(newPassword) 43 | }) 44 | 45 | export const reauthenticate = (email, password) => ({ 46 | type: 'REAUTHENTICATE', 47 | payload: auth.currentUser.reauthenticateAndRetrieveDataWithCredential(firebase.auth.EmailAuthProvider.credential(email, password)) 48 | }) 49 | 50 | export const updateUser = (data) => ({ 51 | type: 'UPDATE_USER', 52 | payload: auth.currentUser.updateProfile(data) 53 | }) 54 | 55 | export const uploadFile = (file, name, path) => ({ 56 | type: 'UPLOAD_FILE', 57 | payload: new Promise((resolve, reject) => { 58 | const storageRef = storage.ref() 59 | 60 | const imageDir = storageRef.child(path) 61 | const task = imageDir.put(file) 62 | 63 | task.on('state_changed', snapshot => { 64 | const progress = Math.ceil((snapshot.bytesTransferred / snapshot.totalBytes) * 100) 65 | console.log(progress) 66 | }, error => reject(error), () => resolve(task.snapshot)) 67 | }) 68 | }) 69 | -------------------------------------------------------------------------------- /src/app/lib/auth/reducer.js: -------------------------------------------------------------------------------- 1 | import { HYDRATE } from 'next-redux-wrapper' 2 | 3 | export const initialState = { 4 | user: null 5 | } 6 | 7 | const reducer = (state = initialState, action) => { 8 | switch (action.type) { 9 | case HYDRATE: 10 | return {...state, ...action.payload.auth} 11 | 12 | case 'SET_USER': 13 | return {...state, user: action.user ? {...action.user} : null} 14 | 15 | default: 16 | return state 17 | } 18 | } 19 | 20 | export default reducer 21 | -------------------------------------------------------------------------------- /src/app/lib/firebase.js: -------------------------------------------------------------------------------- 1 | import Firebase from 'firebase/app' 2 | import 'firebase/auth' 3 | import 'firebase/firestore' 4 | import 'firebase/storage' 5 | 6 | const config = { 7 | apiKey: process.env.FIREBASE_API_KEY, 8 | authDomain: process.env.FIREBASE_AUTH_DOMAIN, 9 | databaseURL: process.env.FIREBASE_DATABASE_URL, 10 | projectId: process.env.FIREBASE_PROJECT_ID, 11 | storageBucket: process.env.FIREBASE_STORAGE_BUCKET, 12 | messagingSenderId: process.env.FIREBASE_MESSAGING_SENDER_ID, 13 | appId: process.env.FIREBASE_APP_ID 14 | } 15 | 16 | if(!Firebase.apps.length) Firebase.initializeApp(config) 17 | 18 | const db = Firebase.firestore() 19 | const auth = Firebase.auth() 20 | const storage = Firebase.storage() 21 | const firebase = Firebase 22 | 23 | export { 24 | db, 25 | auth, 26 | storage, 27 | firebase 28 | } 29 | -------------------------------------------------------------------------------- /src/app/lib/logger.js: -------------------------------------------------------------------------------- 1 | export default store => next => action => { 2 | console.info(action.type, action) 3 | return next(action) 4 | } 5 | -------------------------------------------------------------------------------- /src/app/lib/promiseMiddleware.js: -------------------------------------------------------------------------------- 1 | export const getData = (res) => { 2 | if(!res) return null 3 | 4 | if(res.forEach) { 5 | let data = [] 6 | res.forEach(item => { 7 | data.push({...item.data(), id: item.id}) 8 | }) 9 | return data 10 | } 11 | 12 | if(res.data) { 13 | return {...res.data(), id: res.id} 14 | } 15 | 16 | return res 17 | } 18 | 19 | const isPromise = (v) => v && typeof v.then === 'function' 20 | 21 | const promiseMiddleware = store => next => action => { 22 | if(isPromise(action.payload)) { 23 | 24 | next({type: `${action.type}_REQUEST`}) 25 | 26 | return action.payload.then( 27 | res => { 28 | const data = getData(res) 29 | 30 | next({ 31 | type: `${action.type}_SUCCESS`, 32 | payload: data 33 | }) 34 | 35 | return Promise.resolve(data) 36 | }, 37 | error => { 38 | next({ 39 | type: `${action.type}_FAILURE`, 40 | payload: error 41 | }) 42 | 43 | return Promise.reject(error) 44 | } 45 | ) 46 | 47 | } 48 | 49 | next(action) 50 | } 51 | 52 | export default promiseMiddleware 53 | -------------------------------------------------------------------------------- /src/app/lib/reducer.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux' 2 | import auth, { initialState as initialStateAuth } from './auth/reducer' 3 | import todo, { initialState as initialStateTodo } from './todo/reducer' 4 | 5 | export const initialState = { 6 | auth: initialStateAuth, 7 | todo: initialStateTodo 8 | } 9 | 10 | export default combineReducers({ 11 | auth, 12 | todo 13 | }) 14 | -------------------------------------------------------------------------------- /src/app/lib/store.js: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware } from 'redux' 2 | import { MakeStore, createWrapper, Context, HYDRATE } from 'next-redux-wrapper' 3 | import { composeWithDevTools } from 'redux-devtools-extension' 4 | import thunkMiddleware from 'redux-thunk' 5 | import promiseMiddleware from './promiseMiddleware' 6 | import logger from './logger' 7 | import reducer, { initialState } from './reducer' 8 | 9 | // create a makeStore function 10 | const makeStore = (context) => { 11 | const store = createStore(reducer, initialState, composeWithDevTools(applyMiddleware(thunkMiddleware, promiseMiddleware, logger))) 12 | 13 | return store 14 | } 15 | 16 | // export an assembled wrapper 17 | export const wrapper = createWrapper(makeStore, {debug: true}) 18 | -------------------------------------------------------------------------------- /src/app/lib/todo/actions.js: -------------------------------------------------------------------------------- 1 | import { db } from '../firebase' 2 | 3 | export const todoDeleted = (todoId) => ({ type: 'TODO_DELETED', todoId }) 4 | 5 | export const getTodos = () => ({ 6 | type: 'GET_TODOS', 7 | payload: db.collection('todos').orderBy('createdAt', 'desc').get() 8 | }) 9 | 10 | export const getTodo = (todoId) => ({ 11 | type: 'GET_TODO', 12 | payload: db.collection('todos').doc(todoId).get() 13 | }) 14 | 15 | export const createTodo = (todo) => ({ 16 | type: 'CREATE_TODO', 17 | payload: db.collection('todos').add(todo) 18 | }) 19 | 20 | export const updateTodo = (todoId, todo) => ({ 21 | type: 'UPDATE_TODO', 22 | payload: db.collection('todos').doc(todoId).update(todo) 23 | }) 24 | 25 | export const deleteTodo = (todoId) => ({ 26 | type: 'DELETE_TODO', 27 | payload: db.collection('todos').doc(todoId).delete() 28 | }) 29 | -------------------------------------------------------------------------------- /src/app/lib/todo/reducer.js: -------------------------------------------------------------------------------- 1 | import { HYDRATE } from 'next-redux-wrapper' 2 | 3 | export const initialState = { 4 | todos: [] 5 | } 6 | 7 | const reducer = (state = initialState, action) => { 8 | switch (action.type) { 9 | case HYDRATE: 10 | return {...state, ...action.payload.todo} 11 | 12 | case 'GET_TODOS_SUCCESS': 13 | return {...state, todos: action.payload} 14 | 15 | case 'GET_TODO_SUCCESS': { 16 | const todo = state.todos.find(todo => todo.id === action.payload.id) 17 | 18 | if(todo) { 19 | return {...state, todos: state.todos.map(todo => { 20 | if(todo.id === action.payload.id) return action.payload 21 | return todo 22 | })} 23 | } 24 | 25 | return {...state, todos: [action.payload, ...state.todos]} 26 | } 27 | 28 | case 'TODO_DELETED': 29 | return {...state, todos: state.todos.filter(todo => todo.id !== action.todoId)} 30 | 31 | default: 32 | return state 33 | } 34 | } 35 | 36 | export default reducer 37 | -------------------------------------------------------------------------------- /src/app/lib/withAuthentication.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { connect } from 'react-redux' 3 | import { bindActionCreators } from 'redux' 4 | import { auth } from './firebase' 5 | import { setUser } from './auth/actions' 6 | import SignIn from '../components/auth/SignIn' 7 | import VerifyEmail from '../components/auth/VerifyEmail' 8 | import Header from '../components/Header' 9 | 10 | const withAuthentication = (needsAuthentication) => (Component) => { 11 | 12 | class WithAuthentication extends React.Component { 13 | componentDidMount() { 14 | const { setUser } = this.props 15 | 16 | auth.onAuthStateChanged(user => { 17 | user 18 | ? setUser(user) 19 | : setUser(null) 20 | }) 21 | } 22 | 23 | renderAuth = (children) => ( 24 |
25 |
26 | {children} 27 |
28 | ) 29 | 30 | render() { 31 | const { user } = this.props 32 | 33 | if(!user && needsAuthentication) return this.renderAuth() 34 | 35 | const hasPassword = user && user.providerData.find(provider => provider.providerId === 'password') 36 | 37 | if(user && hasPassword && !user.emailVerified) return this.renderAuth() 38 | 39 | return 40 | } 41 | } 42 | 43 | WithAuthentication.getInitialProps = Component.getInitialProps 44 | 45 | const mapStateToProps = (state) => ({ 46 | user: state.auth.user 47 | }) 48 | 49 | const mapDispatchToProps = (dispatch) => ( 50 | bindActionCreators({ 51 | setUser 52 | }, dispatch) 53 | ) 54 | 55 | return connect(mapStateToProps, mapDispatchToProps)(WithAuthentication) 56 | 57 | } 58 | 59 | export default withAuthentication 60 | -------------------------------------------------------------------------------- /src/app/next.config.js: -------------------------------------------------------------------------------- 1 | const { parsed: localEnv } = require('dotenv').config() 2 | const webpack = require('webpack') 3 | const withSass = require('@zeit/next-sass') 4 | const withImages = require('next-images') 5 | 6 | module.exports = withImages(withSass({ 7 | webpack: (config, options) => { 8 | config.plugins.push(new webpack.EnvironmentPlugin(localEnv)) 9 | 10 | return config 11 | }, 12 | distDir: '../../dist/functions/next' 13 | })) 14 | -------------------------------------------------------------------------------- /src/app/pages/_app.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import App, { AppInitialProps, AppContext } from 'next/app' 3 | import { wrapper } from '../lib/store' 4 | 5 | class MyApp extends App { 6 | 7 | static getInitialProps = async ({ Component, ctx }) => { 8 | 9 | // ctx.store.dispatch({type: 'TOE', payload: 'was set in _app'}) 10 | 11 | return { 12 | pageProps: { 13 | // Call page-level getInitialProps 14 | ...(Component.getInitialProps ? await Component.getInitialProps(ctx) : {}), 15 | // Some custom thing for all pages 16 | pathname: ctx.pathname 17 | } 18 | } 19 | 20 | } 21 | 22 | render() { 23 | const { Component, pageProps } = this.props 24 | 25 | return ( 26 | 27 | ) 28 | } 29 | 30 | } 31 | 32 | export default wrapper.withRedux(MyApp) 33 | -------------------------------------------------------------------------------- /src/app/pages/_document.js: -------------------------------------------------------------------------------- 1 | import Document, { Html, Head, Main, NextScript } from 'next/document' 2 | 3 | class MyDocument extends Document { 4 | static async getInitialProps(ctx) { 5 | const initialProps = await Document.getInitialProps(ctx) 6 | 7 | return { ...initialProps } 8 | } 9 | 10 | render() { 11 | return ( 12 | 13 | 14 | 15 | 16 | 17 |
18 | 19 | 20 | 21 | ) 22 | } 23 | } 24 | 25 | export default MyDocument 26 | -------------------------------------------------------------------------------- /src/app/pages/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import Head from 'next/head' 4 | import compose from 'recompose/compose' 5 | import { connect } from 'react-redux' 6 | import { bindActionCreators } from 'redux' 7 | import { getTodos } from '../lib/todo/actions' 8 | import withAuthentication from '../lib/withAuthentication' 9 | import PageWrapper from '../components/PageWrapper' 10 | import Header from '../components/Header' 11 | import AddTodo from '../components/todo/AddTodo' 12 | import TodoList from '../components/todo/TodoList' 13 | import Footer from '../components/Footer' 14 | import './index.scss' 15 | 16 | class Home extends React.Component { 17 | 18 | static propTypes = { 19 | getTodos: PropTypes.func.isRequired, 20 | user: PropTypes.object 21 | } 22 | 23 | static getInitialProps = async ({ store }) => { 24 | await store.dispatch(getTodos()) 25 | return {} 26 | } 27 | 28 | render = () => { 29 | return ( 30 | 31 |
32 | 33 | 34 | 35 | 36 | Todo list | Nextbase 37 | 38 | 39 |
40 | 41 | 42 | 43 | 44 | 45 |
46 | 47 |
48 |
49 | ) 50 | } 51 | } 52 | 53 | const mapStateToProps = (state) => ({ 54 | user: state.auth.user 55 | }) 56 | 57 | const mapDispatchToProps = (dispatch) => ( 58 | bindActionCreators({ 59 | getTodos 60 | }, dispatch) 61 | ) 62 | 63 | export default compose(withAuthentication(false), connect(mapStateToProps, mapDispatchToProps))(Home) 64 | -------------------------------------------------------------------------------- /src/app/pages/index.scss: -------------------------------------------------------------------------------- 1 | @import '../components/common/global'; 2 | @import '../components/common/variables'; 3 | 4 | .index { 5 | min-height: 100vh; 6 | padding-bottom: 100px; 7 | position: relative; 8 | } 9 | 10 | -------------------------------------------------------------------------------- /src/app/pages/user.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Head from 'next/head' 3 | import compose from 'recompose/compose' 4 | import { connect } from 'react-redux' 5 | import { bindActionCreators } from 'redux' 6 | import withAuthentication from '../lib/withAuthentication' 7 | import PageWrapper from '../components/PageWrapper' 8 | import Modal from '../components/common/Modal' 9 | import SetPwd from '../components/user/SetPwd' 10 | import ChangePwd from '../components/user/ChangePwd' 11 | import ChangeName from '../components/user/ChangeName' 12 | import ImgUpload from '../components/user/ImgUpload' 13 | import Header from '../components/Header' 14 | import Button from '../components/common/Button' 15 | import './user.scss' 16 | 17 | class User extends React.Component { 18 | 19 | state = { 20 | setPwdVisible: false, 21 | changePwdVisible: false, 22 | changeNameVisible: false, 23 | imgUploadVisible: false 24 | } 25 | 26 | render = () => { 27 | const { user } = this.props 28 | const { setPwdVisible, changePwdVisible, changeNameVisible, imgUploadVisible } = this.state 29 | 30 | const hasPassword = !!user.providerData.find(provider => provider.providerId === 'password') 31 | 32 | return ( 33 | 34 |
35 | 36 | 37 | My account | Nextbase 38 | 39 | 40 |
41 | 42 |
43 |
44 |
{user.email}
45 |
{user.displayName}
46 | 47 | 48 | 49 | {hasPassword ? 50 | : 51 | 52 | } 53 |
54 | 55 | this.setState({imgUploadVisible: false})}> 56 | this.setState({imgUploadVisible: false})} /> 57 | 58 | 59 | this.setState({changeNameVisible: false})}> 60 | this.setState({changeNameVisible: false})} /> 61 | 62 | 63 | this.setState({setPwdVisible: false})}> 64 | this.setState({setPwdVisible: false})} /> 65 | 66 | 67 | this.setState({changePwdVisible: false})}> 68 | this.setState({changePwdVisible: false})} /> 69 | 70 |
71 |
72 | ) 73 | } 74 | } 75 | 76 | const mapStateToProps = (state) => ({ 77 | user: state.auth.user 78 | }) 79 | 80 | const mapDispatchToProps = (dispatch) => ( 81 | bindActionCreators({}, dispatch) 82 | ) 83 | 84 | export default compose(withAuthentication(true), connect(mapStateToProps, mapDispatchToProps))(User) 85 | -------------------------------------------------------------------------------- /src/app/pages/user.scss: -------------------------------------------------------------------------------- 1 | @import '../components/common/global'; 2 | @import '../components/common/variables'; 3 | 4 | .user { 5 | 6 | min-height: 100vh; 7 | 8 | .inner { 9 | 10 | padding: 15px; 11 | max-width: 420px; 12 | min-height: 70vh; 13 | margin: 0 auto; 14 | text-align: center; 15 | 16 | @media (min-width: $tablet) { 17 | display: flex; 18 | flex-direction: column; 19 | align-items: center; 20 | justify-content: center; 21 | } 22 | 23 | .photo { 24 | 25 | width: 100px; 26 | height: 100px; 27 | margin: 0 auto 30px; 28 | border-radius: 50%; 29 | background-size: cover; 30 | overflow: hidden; 31 | 32 | @media (min-width: $tablet) { 33 | width: 150px; 34 | height: 150px; 35 | } 36 | 37 | } 38 | 39 | .email { 40 | margin-bottom: 15px; 41 | font-weight: 600; 42 | font-size: 1.1em; 43 | color: #444; 44 | } 45 | 46 | .name { 47 | margin-bottom: 50px; 48 | color: #444; 49 | } 50 | 51 | button { 52 | margin-bottom: 15px; 53 | } 54 | 55 | } 56 | 57 | } 58 | -------------------------------------------------------------------------------- /src/app/postcss.config.js: -------------------------------------------------------------------------------- 1 | const autoprefixer = require('autoprefixer') 2 | 3 | module.exports = { 4 | plugins: [autoprefixer()] 5 | } 6 | -------------------------------------------------------------------------------- /src/app/public/static/img/firebase.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/app/public/static/img/menu.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/public/static/img/mj_white.svg: -------------------------------------------------------------------------------- 1 | Artboard 1 -------------------------------------------------------------------------------- /src/app/public/static/img/nextjs.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | next-black 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/app/public/static/img/react.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 31 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /src/app/public/static/img/redux.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/functions/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "targets": { 7 | "node": "8.14" 8 | } 9 | } 10 | ] 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /src/functions/index.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const functions = require('firebase-functions') 3 | const nodemailer = require('nodemailer') 4 | const next = require('next') 5 | const admin = require('firebase-admin') 6 | admin.initializeApp() 7 | 8 | const dev = process.env.NODE_ENV !== 'production' 9 | const app = next({ 10 | dev, 11 | conf: { distDir: `${path.relative(process.cwd(), __dirname)}/next` } 12 | }) 13 | const handle = app.getRequestHandler() 14 | 15 | exports.next = functions.https.onRequest((req, res) => { 16 | console.log('File: ' + req.originalUrl) // log the page.js file that is being requested 17 | return app.prepare().then(() => handle(req, res)) 18 | }) 19 | 20 | 21 | /* 22 | const gmailEmail = functions.config().gmail.email 23 | const gmailPassword = functions.config().gmail.password 24 | const mailTransport = nodemailer.createTransport({ 25 | service: 'gmail', 26 | auth: { 27 | user: gmailEmail, 28 | pass: gmailPassword 29 | } 30 | }) 31 | 32 | exports.sendWelcomeEmail = functions.auth.user().onCreate(async (user) => { 33 | if(!user.email) return null 34 | 35 | const mailOptions = { 36 | from: '"Nextbase" ', 37 | to: user.email 38 | } 39 | 40 | mailOptions.subject = 'Welcome to Nextbase' 41 | mailOptions.text = 'Thanks for joining Nextbase. This is demonstrational email. You will receive no spam from us :)' 42 | 43 | try { 44 | await mailTransport.sendMail(mailOptions) 45 | } catch(error) { 46 | console.error('There was an error while sending the email:', error) 47 | } 48 | 49 | return null 50 | }) 51 | 52 | //add uploaded img to db 53 | exports.addUploadedImgToDB = functions.storage.object().onFinalize((object) => { 54 | const imgData = { 55 | bucket: object.bucket, 56 | path: object.name 57 | } 58 | 59 | return admin.firestore().collection('images').add(imgData) 60 | }) 61 | */ 62 | -------------------------------------------------------------------------------- /src/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/martyan/nextbase/2e0e915f5ef980752393c48235e65cd3e2218f9a/src/public/favicon.ico --------------------------------------------------------------------------------