├── .env ├── .firebaserc ├── .gitignore ├── README.md ├── firebase.json ├── firestore.indexes.json ├── firestore.rules ├── functions ├── index.js ├── package-lock.json └── package.json ├── package.json ├── public ├── favicon.ico ├── index.html └── manifest.json └── src ├── App.css ├── App.js ├── App.test.js ├── components ├── LayoutHeader.js ├── Loading.css ├── Loading.js ├── PrivateRoute.js ├── Upload.css └── Upload.js ├── config └── firebase.js ├── firebase ├── authencation.js ├── firebase.js ├── firestore.js ├── index.js └── storage.js ├── helpers ├── query-string.js └── strict-uri-encode.js ├── hoc ├── index.js ├── withFirebase.js └── withLoading.js ├── index.css ├── index.js ├── logo.svg ├── modules ├── auth │ ├── actions.js │ ├── constants.js │ ├── index.js │ ├── reducer.js │ ├── saga.js │ ├── selections.js │ ├── service.js │ └── test │ │ ├── actions.test.js │ │ ├── reducer.test.js │ │ └── selections.test.js ├── track │ ├── actions.js │ ├── constants.js │ ├── index.js │ ├── reducer.js │ ├── sagas.js │ ├── selectors.js │ └── service.js └── user │ ├── actions.js │ ├── constants.js │ ├── index.js │ ├── reducer.js │ ├── sagas.js │ ├── selectors.js │ └── service.js ├── pages ├── AppLayout.js ├── login │ ├── Login.js │ ├── Register.js │ └── index.js └── other │ ├── List.js │ ├── NewTrack.js │ ├── Profile.js │ ├── ReviewTrack.js │ ├── UserForm.js │ ├── UserList.js │ ├── components │ └── Track.js │ └── index.js ├── reducers.js ├── registerServiceWorker.js ├── sagas.js └── store └── index.js /.env: -------------------------------------------------------------------------------- 1 | PORT=3002 2 | -------------------------------------------------------------------------------- /.firebaserc: -------------------------------------------------------------------------------- 1 | { 2 | "projects": { 3 | "default": "travelear-1470701732966" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /functions/node_modules 6 | 7 | # testing 8 | /coverage 9 | 10 | # production 11 | /build 12 | 13 | # misc 14 | .DS_Store 15 | .env.local 16 | .env.development.local 17 | .env.test.local 18 | .env.production.local 19 | 20 | npm-debug.log* 21 | yarn-debug.log* 22 | yarn-error.log* 23 | .idea 24 | yarn.lock 25 | /.firebase 26 | -------------------------------------------------------------------------------- /firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "hosting": { 3 | "public": "build", 4 | "ignore": [ 5 | "firebase.json", 6 | "**/.*", 7 | "**/node_modules/**" 8 | ], 9 | "rewrites": [ 10 | { 11 | "source": "**", 12 | "destination": "/index.html" 13 | } 14 | ] 15 | }, 16 | "firestore": { 17 | "rules": "firestore.rules", 18 | "indexes": "firestore.indexes.json" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /firestore.indexes.json: -------------------------------------------------------------------------------- 1 | { 2 | "indexes": [] 3 | } -------------------------------------------------------------------------------- /firestore.rules: -------------------------------------------------------------------------------- 1 | service cloud.firestore { 2 | match /databases/{database}/documents { 3 | match /{document=**} { 4 | allow read: if true; 5 | allow write: if get(/databases/$(database)/documents/users/$(request.auth.uid)).data.role == 'admin'; 6 | } 7 | 8 | match /users/{userId} { 9 | allow write: if request.auth.uid == userId 10 | } 11 | 12 | match /tracks/{trackId} { 13 | allow create: if get(/databases/$(database)/documents/users/$(request.auth.uid)).data.role == 'creator'; 14 | allow delete: if request.auth.uid == resource.data.author && !resource.data.isPublic; 15 | allow update: if request.auth.uid == resource.data.author; 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /functions/index.js: -------------------------------------------------------------------------------- 1 | const functions = require('firebase-functions'); 2 | const admin = require('firebase-admin'); 3 | const _ = require('lodash'); 4 | 5 | admin.initializeApp(functions.config().firebase); 6 | 7 | const db = admin.firestore(); 8 | db.settings({ timestampsInSnapshots: true }); 9 | 10 | const auth = admin.auth(); 11 | 12 | exports.deleteUser = functions 13 | .firestore 14 | .document('users/{uid}') 15 | .onDelete((event, context) => { 16 | const uid = context.params.uid; 17 | return auth.deleteUser(uid) 18 | .then(function() { 19 | return { success: true }; 20 | }); 21 | }); 22 | 23 | exports.deleteTrack = functions 24 | .firestore 25 | .document('tracks/{tid}') 26 | .onDelete((snap, context) => { 27 | const tid = context.params.tid; 28 | const track = snap.data(); 29 | const userRef = db.collection('users').doc(track.author) 30 | 31 | return userRef.get().then(doc => { 32 | userRef.update({ 33 | tracks: _.filter(doc.data().tracks, id => id !== tid) 34 | }); 35 | }); 36 | }); 37 | 38 | exports.addTrack = functions 39 | .firestore 40 | .document('tracks/{tid}') 41 | .onCreate((snap, context) => { 42 | const tid = context.params.tid; 43 | const track = snap.data(); 44 | const userRef = db.collection('users').doc(track.author) 45 | 46 | return userRef.get().then(doc => { 47 | userRef.update({ 48 | tracks: _.concat(doc.data().tracks, tid) 49 | }); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /functions/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "functions", 3 | "description": "Cloud Functions for Firebase", 4 | "scripts": { 5 | "serve": "firebase serve --only functions", 6 | "shell": "firebase functions:shell", 7 | "start": "npm run shell", 8 | "deploy": "firebase deploy --only functions", 9 | "logs": "firebase functions:log" 10 | }, 11 | "dependencies": { 12 | "firebase-admin": "~6.0.0", 13 | "firebase-functions": "^2.0.3", 14 | "lodash": "^4.17.11" 15 | }, 16 | "private": true 17 | } 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "travelear-admin", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "classnames": "^2.2.6", 7 | "currency-formatter": "^1.4.3", 8 | "firebase": "^5.3.0", 9 | "husky": "^0.14.3", 10 | "immutable": "^3.8.2", 11 | "lint-staged": "^7.2.2", 12 | "moment": "^2.22.2", 13 | "numeral": "^2.0.6", 14 | "prettier": "^1.14.2", 15 | "prop-types": "^15.6.2", 16 | "react": "^16.4.2", 17 | "react-dom": "^16.4.2", 18 | "react-loadable": "^5.4.0", 19 | "react-redux": "^5.0.7", 20 | "react-router-dom": "^4.3.1", 21 | "react-scripts": "1.1.5", 22 | "recompose": "^0.28.1", 23 | "redux": "^4.0.0", 24 | "redux-immutable": "^4.0.0", 25 | "redux-saga": "^0.16.0", 26 | "reselect": "^3.0.1", 27 | "uuid": "^3.3.2", 28 | "uuid-v4": "^0.1.0" 29 | }, 30 | "lint-staged": { 31 | "src/**/*.{js,jsx,json,css}": [ 32 | "prettier --single-quote --write", 33 | "git add" 34 | ] 35 | }, 36 | "scripts": { 37 | "start": "react-scripts start", 38 | "build": "react-scripts build", 39 | "test": "react-scripts test --env=jsdom", 40 | "eject": "react-scripts eject", 41 | "precommit": "lint-staged" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dony2100/Travelear-Mocks-admin/0ea8aaecd13b86544c887e48521bce094f48975f/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 13 | 22 | Travelear 23 | 24 | 25 | 28 |
29 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | .flex-column { 2 | display: flex; 3 | flex-direction: column; 4 | } 5 | .flex-full { 6 | flex: 1; 7 | } 8 | .flex-center { 9 | justify-content: center; 10 | align-items: center; 11 | } 12 | .margin-large-button { 13 | margin: 25px 0px; 14 | } 15 | .width-small-button { 16 | width: 200px; 17 | } 18 | .container { 19 | flex: 1; 20 | } 21 | .header { 22 | min-height: 83px; 23 | background-color: #1c2834; 24 | flex-direction: row; 25 | justify-content: space-between; 26 | align-items: center; 27 | display: flex; 28 | } 29 | .header a { 30 | text-decoration-line: none; 31 | } 32 | .header a span { 33 | color: #e74d31; 34 | font-weight: bolder; 35 | font-size: 40px; 36 | margin-left: 50px; 37 | margin-right: 50px; 38 | } 39 | .header-menu { 40 | flex: 1; 41 | flex-direction: row; 42 | justify-content: flex-end; 43 | display: flex; 44 | margin-top: 20px; 45 | } 46 | .header-menu a { 47 | color: #ffffff; 48 | font-size: 19px; 49 | font-weight: bolder; 50 | padding: 20px; 51 | display: block; 52 | } 53 | 54 | .form { 55 | display: flex; 56 | flex-direction: column; 57 | align-items: center; 58 | } 59 | .login { 60 | width: 300px; 61 | margin: 25px 20px 0px 20px; 62 | } 63 | .title-page { 64 | font-size: 28px; 65 | color: #1c2834; 66 | font-weight: bolder; 67 | margin-top: 50px; 68 | margin-bottom: 25px; 69 | display: block; 70 | width: 100%; 71 | } 72 | .detail-container { 73 | width: 400px; 74 | background-color: #ffffff; 75 | padding: 20px; 76 | } 77 | .detail-info { 78 | display: flex; 79 | flex-direction: row; 80 | margin: 20px 0px; 81 | } 82 | .detail-info-right { 83 | margin-left: 15px; 84 | } 85 | .detail-info-right span, 86 | .detail-info-right div { 87 | margin-bottom: 5px; 88 | } 89 | .detail-info-right div span { 90 | margin-right: 10px; 91 | } 92 | .detail-name, 93 | .list-edit { 94 | font-size: 20px; 95 | font-weight: bolder; 96 | color: #1c2834; 97 | } 98 | .button-public-detail { 99 | margin-top: 25px; 100 | } 101 | .new-track { 102 | width: 700px; 103 | } 104 | .new-track img, 105 | .new-track audio { 106 | margin-bottom: 5px; 107 | } 108 | .button-submit-new { 109 | margin-top: 10px; 110 | } 111 | .profile-width-full { 112 | width: 100%; 113 | } 114 | .profile-image { 115 | width: 130px; 116 | height: 130px; 117 | background-color: #d8d8d8; 118 | margin-bottom: 5px; 119 | display: flex; 120 | justify-content: center; 121 | align-items: center; 122 | } 123 | .profile-image span { 124 | font-weight: bolder; 125 | color: #1c2834; 126 | margin: 0px 30px; 127 | text-align: center; 128 | } 129 | .row_new_track { 130 | width: 100%; 131 | display: flex; 132 | flex-direction: row; 133 | } 134 | .row_new_track input:first-of-type { 135 | margin-right: 15px; 136 | } 137 | .row_new_track input:nth-of-type(2) { 138 | margin-left: 15px; 139 | } 140 | .list { 141 | width: 900px; 142 | margin: 25px; 143 | } 144 | .list-detail { 145 | width: 395px; 146 | display: inline-block; 147 | background-color: #ffffff; 148 | margin: 15px 0px; 149 | padding: 20px; 150 | } 151 | .list-detail span a { 152 | margin-right: 10px; 153 | } 154 | .list-detail:nth-child(2n + 1) { 155 | margin-right: 15px; 156 | } 157 | .list-detail:nth-child(2n + 0) { 158 | margin-left: 15px; 159 | } 160 | .audio { 161 | width: 100%; 162 | } 163 | 164 | .error { 165 | color: red; 166 | text-align: left; 167 | width: 100%; 168 | } 169 | -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from 'react'; 2 | import { Route, BrowserRouter as Router, Switch } from 'react-router-dom'; 3 | import { Provider } from 'react-redux'; 4 | import { auth, getDocument } from './firebase'; 5 | import AppLayout from './pages/AppLayout'; 6 | import PrivateRoute from './components/PrivateRoute'; 7 | import configureStore from './store'; 8 | import { LoginPage, RegisterPage } from './pages/login'; 9 | import { Loading } from './components/Loading'; 10 | import './App.css'; 11 | import './index.css'; 12 | 13 | const store = configureStore({ 14 | auth: { 15 | isLogin: false, 16 | user: {}, 17 | loading: false 18 | } 19 | }); 20 | 21 | class App extends PureComponent { 22 | constructor(props) { 23 | super(props); 24 | this.state = { 25 | isReady: false 26 | }; 27 | } 28 | 29 | /** 30 | * Add an observer for changes to the user's sign-in state. 31 | */ 32 | componentDidMount() { 33 | auth.onAuthStateChanged(async user => { 34 | if (user) { 35 | const userInfo = await getDocument('users', user.uid); 36 | const userData = { 37 | uid: user.uid, 38 | email: user.email, 39 | ...userInfo 40 | }; 41 | store.dispatch({ 42 | type: 'auth/LOGIN_SUCCESS', 43 | payload: { user: userData } 44 | }); 45 | } 46 | this.setState({ 47 | isReady: true 48 | }); 49 | }); 50 | } 51 | 52 | render() { 53 | const { isReady } = this.state; 54 | if (!isReady) { 55 | return ; 56 | } 57 | return ( 58 | 59 | 60 | 61 | 62 | 68 | 69 | 70 | 71 | 72 | ); 73 | } 74 | } 75 | 76 | export default App; 77 | -------------------------------------------------------------------------------- /src/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | 5 | it('renders without crashing', () => { 6 | const div = document.createElement('div'); 7 | ReactDOM.render(, div); 8 | }); 9 | -------------------------------------------------------------------------------- /src/components/LayoutHeader.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Link } from 'react-router-dom'; 4 | 5 | class LayoutHeader extends Component { 6 | render() { 7 | const { auth } = this.props; 8 | const { user } = auth; 9 | return ( 10 |
11 | 12 | travelear 13 | 14 | {auth.isLogin && ( 15 |
16 | {user.role === 'admin' && User} 17 | {(user.role === 'admin' || user.role === 'creator') && ( 18 | New 19 | )} 20 | 21 | Profile 22 | 23 | Logout 24 |
25 | )} 26 |
27 | ); 28 | } 29 | } 30 | 31 | LayoutHeader.propTypes = { 32 | auth: PropTypes.object.isRequired, 33 | logout: PropTypes.func.isRequired 34 | }; 35 | 36 | LayoutHeader.defaultProps = {}; 37 | 38 | export default LayoutHeader; 39 | -------------------------------------------------------------------------------- /src/components/Loading.css: -------------------------------------------------------------------------------- 1 | .wrap-loading { 2 | text-align: center; 3 | margin-top: 200px; 4 | } 5 | 6 | .loading { 7 | display: inline-block; 8 | width: 64px; 9 | height: 64px; 10 | } 11 | .loading:after { 12 | content: ' '; 13 | display: block; 14 | width: 46px; 15 | height: 46px; 16 | margin: 1px; 17 | border-radius: 50%; 18 | border: 5px solid #000; 19 | border-color: #000 transparent #000 transparent; 20 | animation: loading 1.2s linear infinite; 21 | } 22 | @keyframes loading { 23 | 0% { 24 | transform: rotate(0deg); 25 | } 26 | 100% { 27 | transform: rotate(360deg); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/components/Loading.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './Loading.css'; 3 | export const Loading = () => ( 4 |
5 |
6 |
7 | ); 8 | -------------------------------------------------------------------------------- /src/components/PrivateRoute.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Route, Redirect, withRouter } from 'react-router-dom'; 4 | import { connect } from 'react-redux'; 5 | import { makeSelectAuth } from '../modules/auth'; 6 | 7 | const PrivateRoute = ({ component: Component, isLogin, ...rest }) => ( 8 | 11 | isLogin ? ( 12 | 13 | ) : ( 14 | 20 | ) 21 | } 22 | /> 23 | ); 24 | 25 | PrivateRoute.propTypes = { 26 | component: PropTypes.any.isRequired, 27 | isLogin: PropTypes.bool.isRequired, 28 | location: PropTypes.object 29 | }; 30 | 31 | PrivateRoute.defaultProps = { 32 | location: {} 33 | }; 34 | 35 | const mapStateToProps = state => { 36 | const { isLogin } = makeSelectAuth(state); 37 | return { 38 | isLogin 39 | }; 40 | }; 41 | 42 | export default withRouter(connect(mapStateToProps)(PrivateRoute)); 43 | -------------------------------------------------------------------------------- /src/components/Upload.css: -------------------------------------------------------------------------------- 1 | .form-input { 2 | width: 100%; 3 | } 4 | 5 | .form-input-upload { 6 | width: 100%; 7 | } 8 | 9 | .form-input-upload input, 10 | .form-input-upload button { 11 | float: left; 12 | } 13 | 14 | .form-input-upload input { 15 | width: calc(100% - 140px); 16 | margin-right: 10px; 17 | } 18 | 19 | .form-input-upload button { 20 | width: 100px; 21 | } 22 | .form-input-upload span { 23 | width: 106px; 24 | height: 32px; 25 | display: block; 26 | background-color: #1c2834; 27 | margin: 5px 0px; 28 | float: right; 29 | color: #ffffff; 30 | font-weight: bolder; 31 | line-height: 32px; 32 | text-align: center; 33 | cursor: pointer; 34 | } 35 | -------------------------------------------------------------------------------- /src/components/Upload.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import uuid from 'uuid'; 4 | 5 | import { uploadFile } from '../firebase/storage'; 6 | import './Upload.css'; 7 | 8 | class Upload extends Component { 9 | constructor(props) { 10 | super(props); 11 | this.state = { 12 | progress: 0 13 | }; 14 | this.onUpload = this.onUpload.bind(this); 15 | this.onClick = this.onClick.bind(this); 16 | this.inputFile = React.createRef(); 17 | } 18 | 19 | onUpload(e) { 20 | const { accept } = this.props; 21 | const { name } = this.props; 22 | const file = e.target.files[0]; 23 | let ref = file.name; 24 | if (accept === 'audio') { 25 | ref = `audio/$${uuid()}-${file.name}`; 26 | } else { 27 | ref = `image/$${uuid()}-${file.name}`; 28 | } 29 | uploadFile(file, ref, progress => this.setState({ progress })) 30 | .then(value => { 31 | this.props.onChange({ 32 | target: { 33 | name, 34 | value 35 | } 36 | }); 37 | }) 38 | .catch(error => console.log(error)); 39 | } 40 | 41 | onClick(e) { 42 | this.inputFile.current.click(); 43 | } 44 | 45 | render() { 46 | const { value, type, placeholder, name, accept } = this.props; 47 | let acceptFile = 'image/*'; 48 | if (accept === 'audio') { 49 | acceptFile = 'audio/*'; 50 | } 51 | 52 | return ( 53 |
54 | {type === 'thumb' ? ( 55 |
56 | {value === '' ? ( 57 | profile image 58 | ) : ( 59 | {placeholder} 60 | )} 61 |
62 | ) : ( 63 |
64 | 70 | Choose 71 |
72 | )} 73 | {this.state.progress ? {this.state.progress}% : null} 74 | 83 |
84 | ); 85 | } 86 | } 87 | 88 | Upload.propTypes = { 89 | onChange: PropTypes.func, 90 | value: PropTypes.string, 91 | accept: PropTypes.oneOf(['image', 'audio']), 92 | type: PropTypes.oneOf(['thumb', 'input']), 93 | name: PropTypes.string, 94 | placeholder: PropTypes.string 95 | }; 96 | 97 | Upload.defaultProps = { 98 | value: '', 99 | accept: 'image', 100 | type: 'thumb', 101 | name: 'image', 102 | placeholder: 'Upload' 103 | }; 104 | 105 | export default Upload; 106 | -------------------------------------------------------------------------------- /src/config/firebase.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Config firebase app 3 | * @type {{apiKey: string, authDomain: string, databaseURL: string, projectId: string, storageBucket: string, messagingSenderId: string}} 4 | */ 5 | export const config = { 6 | apiKey: "AIzaSyBHIkYnZY3Z221RJ3lfO5KShMKMo8EJedg", 7 | authDomain: "travelear-df75e.firebaseapp.com", 8 | databaseURL: "https://travelear-df75e.firebaseio.com", 9 | projectId: "travelear-df75e", 10 | storageBucket: "travelear-df75e.appspot.com", 11 | messagingSenderId: "1035569732494" 12 | }; 13 | 14 | /** 15 | * firestore setting 16 | * @type {{timestampsInSnapshots: boolean}} 17 | */ 18 | export const fireStoreSetting = { 19 | timestampsInSnapshots: true 20 | }; 21 | -------------------------------------------------------------------------------- /src/firebase/authencation.js: -------------------------------------------------------------------------------- 1 | import { auth } from './firebase'; 2 | 3 | /** 4 | * Sign in firebase email and password 5 | * @param email 6 | * @param password 7 | * @param remember 8 | * @returns {Promise} 9 | */ 10 | export function signIn(email, password, remember = false) { 11 | const persistence = remember ? 'local' : 'none'; 12 | return new Promise((resolve, reject) => { 13 | auth 14 | .setPersistence(persistence) 15 | .then(() => 16 | auth 17 | .signInWithEmailAndPassword(email, password) 18 | .then(() => resolve({ success: true })) 19 | ) 20 | .catch(error => reject(error)); 21 | }); 22 | } 23 | 24 | /** 25 | * Sign up user firebase with email and password 26 | * @param email 27 | * @param password 28 | * @returns {Promise} 29 | */ 30 | export function signUp(email, password) { 31 | return new Promise((resolve, reject) => { 32 | auth 33 | .createUserWithEmailAndPassword(email, password) 34 | .then(() => { 35 | const { uid, email } = auth.currentUser; 36 | return resolve({ uid, email }); 37 | }) 38 | .catch(error => reject(error)); 39 | }); 40 | } 41 | 42 | /** 43 | * Sign out 44 | * @returns {Promise} 45 | */ 46 | export function signOut() { 47 | return new Promise((resolve, reject) => { 48 | auth 49 | .signOut() 50 | .then(() => resolve()) 51 | .catch(error => reject(error)); 52 | }); 53 | } 54 | -------------------------------------------------------------------------------- /src/firebase/firebase.js: -------------------------------------------------------------------------------- 1 | import firebase from 'firebase/app'; 2 | 3 | import 'firebase/auth'; 4 | import 'firebase/storage'; 5 | import 'firebase/firestore'; 6 | 7 | import { config, fireStoreSetting } from '../config/firebase'; 8 | 9 | // Initialize the default app 10 | export const defaultApp = firebase.initializeApp(config); 11 | 12 | export const auth = defaultApp.auth(); 13 | export const storage = defaultApp.storage(); 14 | export const firestore = defaultApp.firestore(); 15 | 16 | firestore.settings(fireStoreSetting); 17 | -------------------------------------------------------------------------------- /src/firebase/firestore.js: -------------------------------------------------------------------------------- 1 | import { firestore } from './firebase'; 2 | 3 | /** 4 | * format data from snapshot 5 | * @param entity 6 | * @param ModalData 7 | * @returns {*} 8 | */ 9 | export function unwrapSnapshot(entity, ModalData = null) { 10 | if (ModalData) { 11 | return new ModalData({ id: entity.id, ...entity.data() }); 12 | } 13 | return Object.assign({}, { id: entity.id }, entity.data()); 14 | } 15 | 16 | /** 17 | * Get collection data from path 18 | * @param path 19 | * @param ModalData 20 | * @returns {Promise} 21 | */ 22 | 23 | export const getCollection = async (path, ModalData = null) => 24 | firestore 25 | .collection(path) 26 | .get() 27 | .then(querySnapshot => { 28 | const entities = []; 29 | querySnapshot.forEach(entity => { 30 | entities.push(unwrapSnapshot(entity, ModalData)); 31 | }); 32 | return entities; 33 | }); 34 | 35 | /** 36 | * Add document to collection 37 | * @param collection 38 | * @param data 39 | * @returns {Promise} 40 | */ 41 | export const addDocument = async (collection, data) => 42 | firestore.collection(collection).add(data); 43 | 44 | /** 45 | * Update document to collection 46 | * @param collection 47 | * @param id 48 | * @param data 49 | * @returns {Promise} 50 | */ 51 | export const updateDocument = async (collection, id, data) => 52 | firestore 53 | .collection(collection) 54 | .doc(id) 55 | .update(data); 56 | 57 | /** 58 | * Remove document 59 | * @param collection 60 | * @param id 61 | * @returns {Promise} 62 | */ 63 | export const removeDocument = async (collection, id) => 64 | firestore 65 | .collection(collection) 66 | .doc(id) 67 | .delete(); 68 | 69 | /** 70 | * Get document 71 | * @param collection 72 | * @param id 73 | * @returns {Promise<({}&{id}&any)>} 74 | */ 75 | export const getDocument = async (collection, id) => 76 | firestore 77 | .collection(collection) 78 | .doc(id) 79 | .get() 80 | .then(data => unwrapSnapshot(data)); 81 | 82 | /** 83 | * Firestore Model 84 | */ 85 | export class Firestore { 86 | constructor(actions, modalClass, collection) { 87 | this.actions = actions; 88 | this.modalClass = modalClass; 89 | this.collection = collection; 90 | } 91 | 92 | subscribe(emit, options = {}) { 93 | let query = firestore.collection(this.collection); 94 | // make where query 95 | if ('whereQueries' in options) { 96 | const { whereQueries } = options; 97 | whereQueries.forEach(({ attr, eq, value }) => { 98 | query = query.where(attr, eq, value); 99 | }); 100 | } 101 | // make orderBy query 102 | if ('orderBy' in options) { 103 | const { orderBy } = options; 104 | orderBy.forEach(({ attr, value }) => { 105 | query = query.orderBy(attr, value); 106 | }); 107 | } 108 | return query.onSnapshot( 109 | snapshot => { 110 | snapshot.docChanges().forEach(change => { 111 | if (change.type === 'removed') { 112 | emit(this.actions.onRemove(change.doc.id)); 113 | } else { 114 | emit( 115 | this.actions.onMutate(unwrapSnapshot(change.doc, this.modalClass)) 116 | ); 117 | } 118 | }); 119 | emit(this.actions.onSuccess()); 120 | }, 121 | error => { 122 | console.log(error); 123 | } 124 | ); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/firebase/index.js: -------------------------------------------------------------------------------- 1 | export { auth, firestore, storage } from './firebase'; 2 | export { 3 | Firestore, 4 | getCollection, 5 | addDocument, 6 | updateDocument, 7 | removeDocument, 8 | getDocument 9 | } from './firestore'; 10 | export { signIn, signOut, signUp } from './authencation'; 11 | -------------------------------------------------------------------------------- /src/firebase/storage.js: -------------------------------------------------------------------------------- 1 | import { storage } from './firebase'; 2 | 3 | export function uploadFile(file, name, cb = (progress) => console.log(progress)) { 4 | const storageRef = storage.ref(); 5 | return new Promise((resolve, reject) => { 6 | const uploadTask = storageRef.child(name).put(file, {}); 7 | uploadTask.on( 8 | 'state_changed', 9 | snapshot => { 10 | const progress = 11 | (snapshot.bytesTransferred / snapshot.totalBytes) * 100; 12 | cb(progress); 13 | }, 14 | error => { 15 | // Handle unsuccessful uploads 16 | if (error) { 17 | reject(error); 18 | } 19 | }, 20 | () => { 21 | uploadTask.snapshot.ref.getDownloadURL().then(function(downloadURL) { 22 | resolve(downloadURL); 23 | }); 24 | } 25 | ); 26 | }); 27 | } 28 | -------------------------------------------------------------------------------- /src/helpers/query-string.js: -------------------------------------------------------------------------------- 1 | /** 2 | * query-string npm packages with version > 5 fails to minify 3 | * https://github.com/facebook/create-react-app/blob/master/packages/react-scripts/template/README.md#npm-run-build-fails-to-minify 4 | */ 5 | const strictUriEncode = require('./strict-uri-encode'); 6 | const decodeComponent = require('decode-uri-component'); 7 | 8 | /*eslint-disable */ 9 | function encoderForArrayFormat(options) { 10 | switch (options.arrayFormat) { 11 | case 'index': 12 | return (key, value, index) => { 13 | return value === null 14 | ? [encode(key, options), '[', index, ']'].join('') 15 | : [ 16 | encode(key, options), 17 | '[', 18 | encode(index, options), 19 | ']=', 20 | encode(value, options) 21 | ].join(''); 22 | }; 23 | case 'bracket': 24 | return (key, value) => { 25 | return value === null 26 | ? [encode(key, options), '[]'].join('') 27 | : [encode(key, options), '[]=', encode(value, options)].join(''); 28 | }; 29 | default: 30 | return (key, value) => { 31 | return value === null 32 | ? encode(key, options) 33 | : [encode(key, options), '=', encode(value, options)].join(''); 34 | }; 35 | } 36 | } 37 | 38 | function parserForArrayFormat(options) { 39 | let result; 40 | 41 | switch (options.arrayFormat) { 42 | case 'index': 43 | return (key, value, accumulator) => { 44 | result = /\[(\d*)\]$/.exec(key); 45 | 46 | key = key.replace(/\[\d*\]$/, ''); 47 | 48 | if (!result) { 49 | accumulator[key] = value; 50 | return; 51 | } 52 | 53 | if (accumulator[key] === undefined) { 54 | accumulator[key] = {}; 55 | } 56 | 57 | accumulator[key][result[1]] = value; 58 | }; 59 | case 'bracket': 60 | return (key, value, accumulator) => { 61 | result = /(\[\])$/.exec(key); 62 | key = key.replace(/\[\]$/, ''); 63 | 64 | if (!result) { 65 | accumulator[key] = value; 66 | return; 67 | } 68 | 69 | if (accumulator[key] === undefined) { 70 | accumulator[key] = [value]; 71 | return; 72 | } 73 | 74 | accumulator[key] = [].concat(accumulator[key], value); 75 | }; 76 | default: 77 | return (key, value, accumulator) => { 78 | if (accumulator[key] === undefined) { 79 | accumulator[key] = value; 80 | return; 81 | } 82 | 83 | accumulator[key] = [].concat(accumulator[key], value); 84 | }; 85 | } 86 | } 87 | 88 | function encode(value, options) { 89 | if (options.encode) { 90 | return options.strict ? strictUriEncode(value) : encodeURIComponent(value); 91 | } 92 | 93 | return value; 94 | } 95 | 96 | function decode(value, options) { 97 | if (options.decode) { 98 | return decodeComponent(value); 99 | } 100 | 101 | return value; 102 | } 103 | 104 | function keysSorter(input) { 105 | if (Array.isArray(input)) { 106 | return input.sort(); 107 | } 108 | 109 | if (typeof input === 'object') { 110 | return keysSorter(Object.keys(input)) 111 | .sort((a, b) => Number(a) - Number(b)) 112 | .map(key => input[key]); 113 | } 114 | 115 | return input; 116 | } 117 | 118 | function extract(input) { 119 | const queryStart = input.indexOf('?'); 120 | if (queryStart === -1) { 121 | return ''; 122 | } 123 | return input.slice(queryStart + 1); 124 | } 125 | 126 | function parse(input, options) { 127 | options = Object.assign({ decode: true, arrayFormat: 'none' }, options); 128 | 129 | const formatter = parserForArrayFormat(options); 130 | 131 | // Create an object with no prototype 132 | const ret = Object.create(null); 133 | 134 | if (typeof input !== 'string') { 135 | return ret; 136 | } 137 | 138 | input = input.trim().replace(/^[?#&]/, ''); 139 | 140 | if (!input) { 141 | return ret; 142 | } 143 | 144 | for (const param of input.split('&')) { 145 | let [key, value] = param.replace(/\+/g, ' ').split('='); 146 | 147 | // Missing `=` should be `null`: 148 | // http://w3.org/TR/2012/WD-url-20120524/#collect-url-parameters 149 | value = value === undefined ? null : decode(value, options); 150 | 151 | formatter(decode(key, options), value, ret); 152 | } 153 | 154 | return Object.keys(ret) 155 | .sort() 156 | .reduce((result, key) => { 157 | const value = ret[key]; 158 | if ( 159 | Boolean(value) && 160 | typeof value === 'object' && 161 | !Array.isArray(value) 162 | ) { 163 | // Sort object keys, not values 164 | result[key] = keysSorter(value); 165 | } else { 166 | result[key] = value; 167 | } 168 | 169 | return result; 170 | }, Object.create(null)); 171 | } 172 | 173 | exports.extract = extract; 174 | exports.parse = parse; 175 | 176 | exports.stringify = (obj, options) => { 177 | const defaults = { 178 | encode: true, 179 | strict: true, 180 | arrayFormat: 'none' 181 | }; 182 | 183 | options = Object.assign(defaults, options); 184 | 185 | if (options.sort === false) { 186 | options.sort = () => {}; 187 | } 188 | 189 | const formatter = encoderForArrayFormat(options); 190 | 191 | return obj 192 | ? Object.keys(obj) 193 | .sort(options.sort) 194 | .map(key => { 195 | const value = obj[key]; 196 | 197 | if (value === undefined) { 198 | return ''; 199 | } 200 | 201 | if (value === null) { 202 | return encode(key, options); 203 | } 204 | 205 | if (Array.isArray(value)) { 206 | const result = []; 207 | 208 | for (const value2 of value.slice()) { 209 | if (value2 === undefined) { 210 | continue; 211 | } 212 | 213 | result.push(formatter(key, value2, result.length)); 214 | } 215 | 216 | return result.join('&'); 217 | } 218 | 219 | return encode(key, options) + '=' + encode(value, options); 220 | }) 221 | .filter(x => x.length > 0) 222 | .join('&') 223 | : ''; 224 | }; 225 | 226 | exports.parseUrl = (input, options) => { 227 | return { 228 | url: input.split('?')[0] || '', 229 | query: parse(extract(input), options) 230 | }; 231 | }; 232 | -------------------------------------------------------------------------------- /src/helpers/strict-uri-encode.js: -------------------------------------------------------------------------------- 1 | /*eslint-disable */ 2 | module.exports = str => 3 | encodeURIComponent(str).replace( 4 | /[!'()*]/g, 5 | x => 6 | `%${x 7 | .charCodeAt(0) 8 | .toString(16) 9 | .toUpperCase()}` 10 | ); 11 | -------------------------------------------------------------------------------- /src/hoc/index.js: -------------------------------------------------------------------------------- 1 | export { withLoading } from './withLoading'; 2 | export { 3 | withSubscribe, 4 | withData, 5 | defaultPropsData, 6 | handleSubmit 7 | } from './withFirebase'; 8 | -------------------------------------------------------------------------------- /src/hoc/withFirebase.js: -------------------------------------------------------------------------------- 1 | import { lifecycle, compose, withState, withHandlers } from 'recompose'; 2 | import queryString from '../helpers/query-string'; 3 | import { addDocument, updateDocument, getDocument } from '../firebase'; 4 | 5 | /** 6 | * Higher order function to inject actions to subscribe and unsubscribe something into a component 7 | * @param {function} subscribe 8 | * @param {function} unsubscribe 9 | */ 10 | export function withSubscribe(subscribe, unsubscribe) { 11 | return lifecycle({ 12 | componentWillMount() { 13 | this.props.dispatch(subscribe()); 14 | }, 15 | componentWillUnmount() { 16 | this.props.dispatch(unsubscribe()); 17 | } 18 | }); 19 | } 20 | 21 | /** 22 | * HOC to bind data for a component to a query string in url location and pass it as props to the component 23 | * updateData and updateLoading are state updater. Refer to defaultPropsData HOC 24 | */ 25 | export const withData = lifecycle({ 26 | componentWillMount() { 27 | const { search } = this.props.location; 28 | const { collection, id } = queryString.parse(search); 29 | const { updateData, updateLoading } = this.props; 30 | if (id) { 31 | getDocument(collection, id) 32 | .then(data => { 33 | updateData(data); 34 | updateLoading(false); 35 | }) 36 | .catch(error => { 37 | updateLoading(false); 38 | }); 39 | } else { 40 | updateLoading(false); 41 | } 42 | } 43 | }); 44 | 45 | /** 46 | * Compose multiple state updaters into a single higher-order component 47 | */ 48 | export const defaultPropsData = compose( 49 | withState('loading', 'updateLoading', true), 50 | withState('pending', 'updatePending', false), 51 | withState('data', 'updateData', {}) 52 | ); 53 | 54 | /** 55 | * HOC to inject a function to submit a form 56 | */ 57 | export const handleSubmit = withHandlers({ 58 | handleSave: props => async (id = null, values, collectionName = null) => { 59 | const { search } = props.location; 60 | const { collection } = queryString.parse(search); 61 | if (id) { 62 | return updateDocument(collectionName || collection, id, values); 63 | } 64 | const docRef = await addDocument(collectionName || collection, values); 65 | return updateDocument(collectionName || collection, docRef.id, { id: docRef.id }); 66 | } 67 | }); 68 | -------------------------------------------------------------------------------- /src/hoc/withLoading.js: -------------------------------------------------------------------------------- 1 | import { branch, renderComponent } from 'recompose'; 2 | import { Loading } from '../components/Loading'; 3 | 4 | const isLoading = ({ loading }) => loading; 5 | 6 | /** 7 | * HOC to add a spinner while loading 8 | */ 9 | export const withLoading = branch(isLoading, renderComponent(Loading)); 10 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | html { 2 | min-height: 100%; 3 | display: flex; 4 | flex-direction: column; 5 | } 6 | body { 7 | flex: 1; 8 | margin: 0; 9 | padding: 0; 10 | font-family: sans-serif; 11 | background-color: #f3f3f3; 12 | display: flex; 13 | flex-direction: column; 14 | color: #6f6f6f; 15 | } 16 | input { 17 | width: calc(100% - 20px); 18 | background-color: #ffffff; 19 | border-radius: 0; 20 | border: 1px solid #f2f2f2; 21 | margin: 5px 0px; 22 | padding: 7px 10px; 23 | box-shadow: 0.5px 0.5px gray; 24 | font-weight: bolder; 25 | } 26 | textarea { 27 | width: calc(100% - 20px); 28 | border: none; 29 | padding: 7px 10px; 30 | margin: 5px 0px; 31 | font-weight: bolder; 32 | box-shadow: 0.5px 0.5px gray; 33 | } 34 | button { 35 | background-color: #1c2834; 36 | color: #ffffff; 37 | font-weight: bolder; 38 | font-size: 16px; 39 | width: 240px; 40 | padding: 6px 0px; 41 | margin: 5px 0px; 42 | } 43 | #root { 44 | flex: 1; 45 | display: flex; 46 | flex-direction: column; 47 | } 48 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | import './index.css'; 5 | 6 | ReactDOM.render(, document.getElementById('root')); 7 | -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/modules/auth/actions.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Auth Actions 3 | */ 4 | 5 | import * as Actions from './constants'; 6 | 7 | /** 8 | * Login action 9 | * @param email 10 | * @param password 11 | * @param remember 12 | * @returns {{type, payload: {email: *, password: *, remember: boolean}}} 13 | */ 14 | export function login(email, password, remember = false) { 15 | return { 16 | type: Actions.LOGIN_REQUEST, 17 | payload: { 18 | email, 19 | password, 20 | remember 21 | } 22 | }; 23 | } 24 | 25 | /** 26 | * Logout action 27 | * @returns {{type}} 28 | */ 29 | export function logout() { 30 | return { 31 | type: Actions.LOGOUT_REQUEST 32 | }; 33 | } 34 | 35 | /** 36 | * Register action 37 | * @param email 38 | * @param password 39 | * @returns {{type: string, payload: {email: *, password: *}}} 40 | */ 41 | export function register(email, password) { 42 | return { 43 | type: Actions.SIGN_UP_REQUEST, 44 | payload: { 45 | email, 46 | password 47 | } 48 | }; 49 | } 50 | -------------------------------------------------------------------------------- /src/modules/auth/constants.js: -------------------------------------------------------------------------------- 1 | export const LOGIN_REQUEST = 'auth/LOGIN_REQUEST'; 2 | export const LOGIN_SUCCESS = 'auth/LOGIN_SUCCESS'; 3 | export const LOGIN_ERROR = 'auth/LOGIN_ERROR'; 4 | 5 | export const LOGOUT_REQUEST = 'auth/LOGOUT_REQUEST'; 6 | export const LOGOUT_SUCCESS = 'auth/LOGOUT_SUCCESS'; 7 | export const LOGOUT_ERROR = 'auth/LOGOUT_ERROR'; 8 | 9 | export const SIGN_UP_REQUEST = 'auth/SIGN_UP_REQUEST'; 10 | export const SIGN_UP_ERROR = 'auth/SIGN_UP_ERROR'; 11 | -------------------------------------------------------------------------------- /src/modules/auth/index.js: -------------------------------------------------------------------------------- 1 | import authSaga from './saga'; 2 | import authReducer from './reducer'; 3 | 4 | export { login, logout, register } from './actions'; 5 | export { authSaga, authReducer }; 6 | export { makeSelectAuth } from './selections'; 7 | -------------------------------------------------------------------------------- /src/modules/auth/reducer.js: -------------------------------------------------------------------------------- 1 | import { fromJS } from 'immutable'; 2 | import * as Actions from './constants'; 3 | 4 | const initState = fromJS({ 5 | loading: false, 6 | isLogin: false, 7 | user: {} 8 | }); 9 | 10 | export default function authReducer(state = initState, action = {}) { 11 | switch (action.type) { 12 | case Actions.LOGIN_REQUEST: 13 | return state.set('loading', true); 14 | case Actions.LOGIN_SUCCESS: 15 | return state.merge({ 16 | loading: false, 17 | isLogin: true, 18 | user: action.payload.user 19 | }); 20 | case Actions.LOGOUT_REQUEST: 21 | case Actions.LOGIN_ERROR: 22 | case Actions.LOGOUT_SUCCESS: 23 | return initState; 24 | default: 25 | return state; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/modules/auth/saga.js: -------------------------------------------------------------------------------- 1 | import { call, put, takeLatest } from 'redux-saga/effects'; 2 | import * as Actions from './constants'; 3 | import { signInRequest, signOutRequest, signUpRequest } from './service'; 4 | import { auth, getDocument, updateDocument } from '../../firebase'; 5 | 6 | /** 7 | * login effect 8 | * @param {{ payload: object }} param0 9 | */ 10 | export function* loginSaga({ payload }) { 11 | const { email, password, remember } = payload; 12 | try { 13 | yield call(signInRequest, { email, password, remember }); 14 | const uid = auth.currentUser.uid; 15 | const user = yield call(getDocument, 'users', uid); 16 | yield put({ 17 | type: 'auth/LOGIN_SUCCESS', 18 | payload: { user } 19 | }); 20 | } catch (error) { 21 | console.log(error); 22 | yield put({ 23 | type: Actions.LOGIN_ERROR, 24 | payload: { error } 25 | }); 26 | } 27 | } 28 | 29 | /** 30 | * register effect 31 | * @param {{ payload: object }} param0 32 | */ 33 | export function* registerSaga({ payload }) { 34 | const { email, password } = payload; 35 | try { 36 | const user = yield call(signUpRequest, { email, password }); 37 | const userData = { id: user.uid, role: 'user', email: user.email, uid: user.uid, joined: new Date() }; 38 | yield call(updateDocument, 'users', user.uid, userData); 39 | yield put({ 40 | type: 'auth/LOGIN_SUCCESS', 41 | payload: { user: userData } 42 | }); 43 | } catch (error) { 44 | console.log(error); 45 | yield put({ 46 | type: Actions.SIGN_UP_ERROR, 47 | payload: { error } 48 | }); 49 | } 50 | } 51 | 52 | /** 53 | * logout effect 54 | * @param {{ payload: object }} param0 55 | */ 56 | function* logoutSaga() { 57 | try { 58 | yield call(signOutRequest); 59 | yield put({ type: Actions.LOGOUT_SUCCESS }); 60 | } catch (error) { 61 | console.log(error); 62 | } 63 | } 64 | 65 | /** 66 | * Spawns a saga on each action dispatched to the store that matches pattern. and automatically cancels any previous saga task started previous if it's still running 67 | */ 68 | export default function* authSaga() { 69 | yield takeLatest(Actions.LOGIN_REQUEST, loginSaga); 70 | yield takeLatest(Actions.SIGN_UP_REQUEST, registerSaga); 71 | yield takeLatest(Actions.LOGOUT_REQUEST, logoutSaga); 72 | } 73 | -------------------------------------------------------------------------------- /src/modules/auth/selections.js: -------------------------------------------------------------------------------- 1 | import { createSelector } from 'reselect'; 2 | 3 | const selectAuth = state => state.get('auth'); 4 | 5 | export const makeSelectAuth = createSelector(selectAuth, auth => auth.toJS()); 6 | -------------------------------------------------------------------------------- /src/modules/auth/service.js: -------------------------------------------------------------------------------- 1 | import { signIn, signOut, signUp } from '../../firebase'; 2 | 3 | /** 4 | * firebase sign in api 5 | * @param {object} input payload 6 | */ 7 | const signInRequest = async input => { 8 | const { email, password, remember } = input; 9 | await signIn(email, password, remember); 10 | }; 11 | 12 | /** 13 | * firebase sign up api 14 | * @param {object} param0 payload 15 | */ 16 | const signUpRequest = ({ email, password }) => signUp(email, password); 17 | 18 | /** 19 | * firebase sign out api 20 | */ 21 | const signOutRequest = async () => { 22 | await signOut(); 23 | }; 24 | export { signInRequest, signOutRequest, signUpRequest }; 25 | -------------------------------------------------------------------------------- /src/modules/auth/test/actions.test.js: -------------------------------------------------------------------------------- 1 | import { logout, login } from '../actions'; 2 | import { LOGIN_REQUEST, LOGOUT_REQUEST } from '../constants'; 3 | 4 | describe('Auth actions', () => { 5 | describe('Logout Action', () => { 6 | it('has a type of LOGOUT_REQUEST', () => { 7 | const expected = { 8 | type: LOGOUT_REQUEST 9 | }; 10 | expect(logout()).toEqual(expected); 11 | }); 12 | }); 13 | 14 | describe('Login Action', () => { 15 | it('has a type of LOGIN_REQUEST and login info', () => { 16 | const email = 'isuperdev007@gmail.com'; 17 | const password = '123456'; 18 | const expected = { 19 | type: LOGIN_REQUEST, 20 | payload: { 21 | email, 22 | password, 23 | remember: false 24 | } 25 | }; 26 | expect(login(email, password)).toEqual(expected); 27 | }); 28 | 29 | it('has a type of LOGIN_REQUEST and login info and remember property', () => { 30 | const email = 'isuperdev007@gmail.com'; 31 | const password = '123456'; 32 | const expected = { 33 | type: LOGIN_REQUEST, 34 | payload: { 35 | email, 36 | password, 37 | remember: true 38 | } 39 | }; 40 | expect(login(email, password, true)).toEqual(expected); 41 | }); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /src/modules/auth/test/reducer.test.js: -------------------------------------------------------------------------------- 1 | import { fromJS } from 'immutable'; 2 | import authReducer from '../reducer'; 3 | import { LOGIN_SUCCESS, LOGIN_REQUEST, LOGOUT_SUCCESS } from '../constants'; 4 | 5 | describe('Reducer', () => { 6 | it('should handle init app', () => { 7 | const state = undefined; 8 | const action = { type: '' }; 9 | const stateInit = authReducer(state, action).toJS(); 10 | expect(stateInit.loading).toBeFalsy(); 11 | expect(stateInit.isLogin).toBeFalsy(); 12 | expect(stateInit.user).toEqual({}); 13 | }); 14 | 15 | it('should handle login success', () => { 16 | const initState = fromJS({ 17 | loading: false, 18 | isLogin: false, 19 | user: {} 20 | }); 21 | const user = { 22 | email: 'isuperdev007@gmail.com', 23 | uid: '111222333' 24 | }; 25 | const expectedState = fromJS({ 26 | loading: false, 27 | isLogin: true, 28 | user 29 | }); 30 | const action = { 31 | type: LOGIN_SUCCESS, 32 | payload: { user } 33 | }; 34 | const state = authReducer(initState, action); 35 | expect(state.toJS()).toEqual(expectedState.toJS()); 36 | expect(state.getIn(['user', 'uid'])).toBe('111222333'); 37 | }); 38 | 39 | it('should handle login request', () => { 40 | const initState = fromJS({ 41 | loading: false, 42 | isLogin: false, 43 | user: {} 44 | }); 45 | const expectedState = fromJS({ 46 | loading: true, 47 | isLogin: false, 48 | user: {} 49 | }); 50 | const user = { 51 | email: 'isuperdev007@gmail.com', 52 | uid: '111222333' 53 | }; 54 | const action = { 55 | type: LOGIN_REQUEST, 56 | payload: { user } 57 | }; 58 | const state = authReducer(initState, action); 59 | expect(state.toJS()).toEqual(expectedState.toJS()); 60 | }); 61 | 62 | it('should handle logout request', () => { 63 | const user = { 64 | email: 'isuperdev007@gmail.com', 65 | uid: '111222333' 66 | }; 67 | const initState = fromJS({ 68 | loading: false, 69 | isLogin: true, 70 | user 71 | }); 72 | const expectedState = fromJS({ 73 | loading: false, 74 | isLogin: false, 75 | user: {} 76 | }); 77 | const action = { 78 | type: LOGOUT_SUCCESS 79 | }; 80 | const state = authReducer(initState, action); 81 | expect(state.toJS()).toEqual(expectedState.toJS()); 82 | }); 83 | }); 84 | -------------------------------------------------------------------------------- /src/modules/auth/test/selections.test.js: -------------------------------------------------------------------------------- 1 | import { fromJS } from 'immutable'; 2 | import { makeSelectAuth } from '../selections'; 3 | 4 | describe('Select auth', () => { 5 | it('select auth from state', () => { 6 | const state = fromJS({ 7 | auth: { 8 | email: 'admin@gmail.com', 9 | uid: '111222333' 10 | } 11 | }); 12 | expect(makeSelectAuth(state)).toEqual(state.get('auth').toJS()); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /src/modules/track/actions.js: -------------------------------------------------------------------------------- 1 | import { SUBSCRIBE, UNSUBSCRIBE, REMOVE_REQUEST } from './constants'; 2 | 3 | /** 4 | * Subscribe track 5 | * @returns {{type}} 6 | */ 7 | export function subscribe() { 8 | return { 9 | type: SUBSCRIBE 10 | }; 11 | } 12 | 13 | /** 14 | * Un subscribe track 15 | * @returns {{type}} 16 | */ 17 | export function unsubscribe() { 18 | return { 19 | type: UNSUBSCRIBE 20 | }; 21 | } 22 | 23 | /** 24 | * Remove track 25 | * @param id 26 | * @returns {{type, payload: {id: *}}} 27 | */ 28 | export function remove(id) { 29 | return { 30 | type: REMOVE_REQUEST, 31 | payload: { 32 | id 33 | } 34 | }; 35 | } 36 | -------------------------------------------------------------------------------- /src/modules/track/constants.js: -------------------------------------------------------------------------------- 1 | export const SUBSCRIBE = 'module/track/SUBSCRIBE'; 2 | export const UNSUBSCRIBE = 'module/track/UNSUBSCRIBE'; 3 | export const SUCCESS_SUBSCRIBE = 'module/track/SUCCESS_SUBSCRIBE'; 4 | 5 | export const REMOVE_REQUEST = 'module/track/REMOVE_REQUEST'; 6 | 7 | export const MUTATE = 'module/track/MUTATE'; 8 | export const REMOVED = 'module/track/REMOVED'; 9 | -------------------------------------------------------------------------------- /src/modules/track/index.js: -------------------------------------------------------------------------------- 1 | import trackSagas from './sagas'; 2 | 3 | export { subscribe, unsubscribe, remove } from './actions'; 4 | export { trackReducer } from './reducer'; 5 | export { makeSelectTrack, makeSelectPending } from './selectors'; 6 | export { trackSagas }; 7 | -------------------------------------------------------------------------------- /src/modules/track/reducer.js: -------------------------------------------------------------------------------- 1 | import { fromJS } from 'immutable'; 2 | import { MUTATE, REMOVED, SUBSCRIBE, SUCCESS_SUBSCRIBE } from './constants'; 3 | import * as Actions from '../auth/constants'; 4 | 5 | export const initState = new fromJS({ 6 | loading: false, 7 | data: [] 8 | }); 9 | 10 | export function trackReducer(state = initState, { type, payload }) { 11 | const data = state.get('data'); 12 | switch (type) { 13 | case SUBSCRIBE: 14 | return state.set('loading', true); 15 | case SUCCESS_SUBSCRIBE: 16 | return state.set('loading', false); 17 | case MUTATE: 18 | // Update data 19 | if (data.find(value => value.get('id') === payload.data.get('id'))) { 20 | return state.set( 21 | 'data', 22 | data.map(value => { 23 | if (value.get('id') === payload.data.get('id')) { 24 | return payload.data; 25 | } 26 | return value; 27 | }) 28 | ); 29 | } 30 | // Add new data 31 | return state.set('data', data.push(payload.data)); 32 | case REMOVED: 33 | return state.set( 34 | 'data', 35 | data.filter(value => value.get('id') !== payload.id) 36 | ); 37 | case Actions.LOGOUT_SUCCESS: 38 | return initState; 39 | default: 40 | return state; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/modules/track/sagas.js: -------------------------------------------------------------------------------- 1 | import { eventChannel } from 'redux-saga'; 2 | import { 3 | call, 4 | cancel, 5 | fork, 6 | put, 7 | take, 8 | takeEvery, 9 | } from 'redux-saga/effects'; 10 | import { SUBSCRIBE, UNSUBSCRIBE, REMOVE_REQUEST } from './constants'; 11 | import { removeService, subscribeService } from './service'; 12 | 13 | /** 14 | * saga effect 15 | */ 16 | function* subscribe() { 17 | let options = { 18 | orderBy: [ 19 | { attr: 'timestamp', value: 'desc' } 20 | ] 21 | }; 22 | const channel = yield eventChannel(emit => subscribeService(emit, options)); 23 | while (true) { 24 | const action = yield take(channel); 25 | yield put(action); 26 | } 27 | } 28 | 29 | /** 30 | * saga effect 31 | */ 32 | function* watchRequestSubscribeTrack() { 33 | while (true) { 34 | yield take(SUBSCRIBE); 35 | const chanel = yield fork(subscribe); 36 | 37 | yield take(UNSUBSCRIBE); 38 | yield cancel(chanel); 39 | } 40 | } 41 | 42 | /** 43 | * saga effect 44 | */ 45 | function* removeTrack({ payload }) { 46 | const { id } = payload; 47 | try { 48 | yield call(removeService, id); 49 | } catch (error) { 50 | console.log(error); 51 | } 52 | } 53 | 54 | export default function* trackSagas() { 55 | yield fork(watchRequestSubscribeTrack); 56 | yield takeEvery(REMOVE_REQUEST, removeTrack); 57 | } 58 | -------------------------------------------------------------------------------- /src/modules/track/selectors.js: -------------------------------------------------------------------------------- 1 | import { createSelector } from 'reselect'; 2 | 3 | // select Track 4 | const selectTrack = state => state.get('track'); 5 | 6 | // make select Track 7 | export const makeSelectTrack = createSelector(selectTrack, track => 8 | track.toJS() 9 | ); 10 | 11 | // make select Pending 12 | export const makeSelectPending = createSelector( 13 | selectTrack, 14 | track => track.toJS().pending 15 | ); 16 | -------------------------------------------------------------------------------- /src/modules/track/service.js: -------------------------------------------------------------------------------- 1 | import { Record } from 'immutable'; 2 | import { Firestore, removeDocument } from '../../firebase'; 3 | import { MUTATE, REMOVED, SUCCESS_SUBSCRIBE } from './constants'; 4 | 5 | const collection = 'tracks'; 6 | 7 | const Track = new Record({ 8 | id: null, 9 | name: '', 10 | author: '', 11 | authorName: '', 12 | duration: '', 13 | location: '', 14 | image: {}, 15 | file: '', 16 | latitude: '', 17 | longitude: '', 18 | plays: '', 19 | recorded: '', 20 | bio: '', 21 | tags: 0, 22 | isPublic: false, 23 | timestamp: '', 24 | }); 25 | 26 | const Model = new Firestore( 27 | { 28 | onMutate: data => ({ 29 | type: MUTATE, 30 | payload: { data } 31 | }), 32 | onRemove: id => ({ 33 | type: REMOVED, 34 | payload: { id } 35 | }), 36 | onSuccess: () => ({ type: SUCCESS_SUBSCRIBE }) 37 | }, 38 | Track, 39 | collection 40 | ); 41 | 42 | export const subscribeService = (emit, options) => 43 | Model.subscribe(emit, options); 44 | export const removeService = id => removeDocument(collection, id); 45 | -------------------------------------------------------------------------------- /src/modules/user/actions.js: -------------------------------------------------------------------------------- 1 | import { SUBSCRIBE, UNSUBSCRIBE, REMOVE_REQUEST } from './constants'; 2 | 3 | /** 4 | * Subscribe user 5 | * @returns {{type}} 6 | */ 7 | export function subscribe() { 8 | return { 9 | type: SUBSCRIBE 10 | }; 11 | } 12 | 13 | /** 14 | * Un subscribe user 15 | * @returns {{type}} 16 | */ 17 | export function unsubscribe() { 18 | return { 19 | type: UNSUBSCRIBE 20 | }; 21 | } 22 | 23 | /** 24 | * Remove user 25 | * @param id 26 | * @returns {{type, payload: {id: *}}} 27 | */ 28 | export function remove(id) { 29 | return { 30 | type: REMOVE_REQUEST, 31 | payload: { 32 | id 33 | } 34 | }; 35 | } 36 | -------------------------------------------------------------------------------- /src/modules/user/constants.js: -------------------------------------------------------------------------------- 1 | export const SUBSCRIBE = 'module/user/SUBSCRIBE'; 2 | export const UNSUBSCRIBE = 'module/user/UNSUBSCRIBE'; 3 | export const SUCCESS_SUBSCRIBE = 'module/user/SUCCESS_SUBSCRIBE'; 4 | 5 | export const REMOVE_REQUEST = 'module/user/REMOVE_REQUEST'; 6 | 7 | export const MUTATE = 'module/user/MUTATE'; 8 | export const REMOVED = 'module/user/REMOVED'; 9 | -------------------------------------------------------------------------------- /src/modules/user/index.js: -------------------------------------------------------------------------------- 1 | import userSagas from './sagas'; 2 | 3 | export { subscribe, unsubscribe, remove } from './actions'; 4 | export { userReducer } from './reducer'; 5 | export { makeSelectUser, makeSelectPending } from './selectors'; 6 | export { userSagas }; 7 | -------------------------------------------------------------------------------- /src/modules/user/reducer.js: -------------------------------------------------------------------------------- 1 | import { fromJS } from 'immutable'; 2 | import { MUTATE, REMOVED, SUBSCRIBE, SUCCESS_SUBSCRIBE } from './constants'; 3 | import * as Actions from '../auth/constants'; 4 | 5 | export const initState = new fromJS({ 6 | loading: false, 7 | data: [] 8 | }); 9 | 10 | export function userReducer(state = initState, { type, payload }) { 11 | const data = state.get('data'); 12 | switch (type) { 13 | case SUBSCRIBE: 14 | return state.set('loading', true); 15 | case SUCCESS_SUBSCRIBE: 16 | return state.set('loading', false); 17 | case MUTATE: 18 | // Update data 19 | if (data.find(value => value.get('id') === payload.data.get('id'))) { 20 | return state.set( 21 | 'data', 22 | data.map(value => { 23 | if (value.get('id') === payload.data.get('id')) { 24 | return payload.data; 25 | } 26 | return value; 27 | }) 28 | ); 29 | } 30 | // Add new data 31 | return state.set('data', data.push(payload.data)); 32 | case REMOVED: 33 | return state.set( 34 | 'data', 35 | data.filter(value => value.get('id') !== payload.id) 36 | ); 37 | case Actions.LOGOUT_SUCCESS: 38 | return initState; 39 | default: 40 | return state; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/modules/user/sagas.js: -------------------------------------------------------------------------------- 1 | import { eventChannel } from 'redux-saga'; 2 | import { call, cancel, fork, put, take, takeEvery } from 'redux-saga/effects'; 3 | import { SUBSCRIBE, UNSUBSCRIBE, REMOVE_REQUEST } from './constants'; 4 | import { removeService, subscribeService } from './service'; 5 | 6 | function* subscribe() { 7 | let options = {}; 8 | const channel = yield eventChannel(emit => subscribeService(emit, options)); 9 | while (true) { 10 | const action = yield take(channel); 11 | yield put(action); 12 | } 13 | } 14 | 15 | function* watchRequestSubscribeTrack() { 16 | while (true) { 17 | yield take(SUBSCRIBE); 18 | const chanel = yield fork(subscribe); 19 | 20 | yield take(UNSUBSCRIBE); 21 | yield cancel(chanel); 22 | } 23 | } 24 | 25 | function* removeTrack({ payload }) { 26 | const { id } = payload; 27 | try { 28 | yield call(removeService, id); 29 | } catch (error) { 30 | console.log(error); 31 | } 32 | } 33 | 34 | export default function* trackSagas() { 35 | yield fork(watchRequestSubscribeTrack); 36 | yield takeEvery(REMOVE_REQUEST, removeTrack); 37 | } 38 | -------------------------------------------------------------------------------- /src/modules/user/selectors.js: -------------------------------------------------------------------------------- 1 | import { createSelector } from 'reselect'; 2 | 3 | // select User 4 | const selectUser = state => state.get('user'); 5 | 6 | // make select User 7 | export const makeSelectUser = createSelector(selectUser, track => track.toJS()); 8 | 9 | // make select Pending 10 | export const makeSelectPending = createSelector( 11 | selectUser, 12 | track => track.toJS().pending 13 | ); 14 | -------------------------------------------------------------------------------- /src/modules/user/service.js: -------------------------------------------------------------------------------- 1 | import { Record } from 'immutable'; 2 | import { Firestore, removeDocument } from '../../firebase'; 3 | import { MUTATE, REMOVED, SUCCESS_SUBSCRIBE } from './constants'; 4 | 5 | const collection = 'users'; 6 | 7 | const Track = new Record({ 8 | id: null, 9 | email: '', 10 | role: '' 11 | }); 12 | 13 | const Model = new Firestore( 14 | { 15 | onMutate: data => ({ 16 | type: MUTATE, 17 | payload: { data } 18 | }), 19 | onRemove: id => ({ 20 | type: REMOVED, 21 | payload: { id } 22 | }), 23 | onSuccess: () => ({ type: SUCCESS_SUBSCRIBE }) 24 | }, 25 | Track, 26 | collection 27 | ); 28 | 29 | export const subscribeService = (emit, options) => 30 | Model.subscribe(emit, options); 31 | export const removeService = id => removeDocument(collection, id); 32 | -------------------------------------------------------------------------------- /src/pages/AppLayout.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import { Switch, Route, withRouter } from 'react-router-dom'; 5 | import { connect } from 'react-redux'; 6 | import { makeSelectAuth, logout } from '../modules/auth'; 7 | import LayoutHeader from '../components/LayoutHeader'; 8 | 9 | import { 10 | ListPage, 11 | NewTrack, 12 | Profile, 13 | ReviewTrack, 14 | UserForm, 15 | UserList 16 | } from './other'; 17 | 18 | class AppLayout extends Component { 19 | constructor(props) { 20 | super(props); 21 | this.logout = this.logout.bind(this); 22 | } 23 | logout(e) { 24 | e.preventDefault(); 25 | this.props.dispatch(logout()); 26 | } 27 | render() { 28 | const { auth } = this.props; 29 | const { 30 | user: { role } 31 | } = auth; 32 | return ( 33 |
34 | 35 | 36 | 37 | 43 | 49 | 55 | {role !== 'user' && ( 56 | 61 | )} 62 | 68 | 73 |

Not found

} /> 74 |
75 |
76 | ); 77 | } 78 | } 79 | 80 | AppLayout.propTypes = { 81 | dispatch: PropTypes.func.isRequired, 82 | location: PropTypes.object.isRequired 83 | }; 84 | 85 | const mapStateToProps = state => { 86 | const auth = makeSelectAuth(state); 87 | return { 88 | auth 89 | }; 90 | }; 91 | 92 | export default withRouter(connect(mapStateToProps)(AppLayout)); 93 | -------------------------------------------------------------------------------- /src/pages/login/Login.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import { connect } from 'react-redux'; 5 | import { Redirect } from 'react-router-dom'; 6 | 7 | import { login } from '../../modules/auth'; 8 | 9 | class Login extends Component { 10 | constructor(props) { 11 | super(props); 12 | this.state = { 13 | username: '', 14 | password: '' 15 | }; 16 | this.onLogin = this.onLogin.bind(this); 17 | } 18 | onLogin(e) { 19 | e.preventDefault(); 20 | const { username, password } = this.state; 21 | this.props.dispatch(login(username, password, true)); 22 | } 23 | render() { 24 | const { 25 | auth: { isLogin } 26 | } = this.props; 27 | const { username, password } = this.state; 28 | if (isLogin) { 29 | return ; 30 | } 31 | return ( 32 |
33 |

Login Page

34 |
35 | this.setState({ username: e.target.value })} 39 | name="username" 40 | placeholder="Username" 41 | /> 42 | this.setState({ password: e.target.value })} 46 | name="password" 47 | placeholder="Password" 48 | /> 49 | 52 |
53 |
54 | ); 55 | } 56 | } 57 | 58 | Login.propTypes = { 59 | dispatch: PropTypes.func.isRequired, 60 | auth: PropTypes.shape({ 61 | isLogin: PropTypes.bool.isRequired, 62 | loading: PropTypes.bool.isRequired 63 | }).isRequired 64 | }; 65 | 66 | Login.defaultProps = {}; 67 | 68 | const mapStateToProps = state => ({ 69 | auth: state.get('auth').toJS() 70 | }); 71 | 72 | const mapDispatchToProps = dispatch => ({ 73 | dispatch 74 | }); 75 | 76 | export default connect( 77 | mapStateToProps, 78 | mapDispatchToProps 79 | )(Login); 80 | -------------------------------------------------------------------------------- /src/pages/login/Register.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import { connect } from 'react-redux'; 5 | import { Redirect } from 'react-router-dom'; 6 | 7 | import { register } from '../../modules/auth'; 8 | 9 | class Login extends Component { 10 | constructor(props) { 11 | super(props); 12 | this.state = { 13 | username: '', 14 | password: '' 15 | }; 16 | this.onSubmit = this.onSubmit.bind(this); 17 | } 18 | onSubmit(e) { 19 | e.preventDefault(); 20 | const { username, password } = this.state; 21 | this.props.dispatch(register(username, password)); 22 | } 23 | render() { 24 | const { 25 | auth: { isLogin } 26 | } = this.props; 27 | const { username, password } = this.state; 28 | if (isLogin) { 29 | return ; 30 | } 31 | return ( 32 |
33 |

Register Page

34 |
35 | this.setState({ username: e.target.value })} 39 | name="username" 40 | placeholder="Username" 41 | /> 42 | this.setState({ password: e.target.value })} 46 | name="password" 47 | placeholder="Password" 48 | /> 49 | 52 |
53 |
54 | ); 55 | } 56 | } 57 | 58 | Login.propTypes = { 59 | dispatch: PropTypes.func.isRequired, 60 | auth: PropTypes.shape({ 61 | isLogin: PropTypes.bool.isRequired, 62 | loading: PropTypes.bool.isRequired 63 | }).isRequired 64 | }; 65 | 66 | Login.defaultProps = {}; 67 | 68 | const mapStateToProps = state => ({ 69 | auth: state.get('auth').toJS() 70 | }); 71 | 72 | const mapDispatchToProps = dispatch => ({ 73 | dispatch 74 | }); 75 | 76 | export default connect( 77 | mapStateToProps, 78 | mapDispatchToProps 79 | )(Login); 80 | -------------------------------------------------------------------------------- /src/pages/login/index.js: -------------------------------------------------------------------------------- 1 | import loadable from 'react-loadable'; 2 | 3 | // Use react-loadable to make component-centric code splitting. 4 | 5 | export const LoginPage = loadable({ 6 | loader: () => import('./Login'), 7 | loading: () => null 8 | }); 9 | 10 | export const RegisterPage = loadable({ 11 | loader: () => import('./Register'), 12 | loading: () => null 13 | }); 14 | -------------------------------------------------------------------------------- /src/pages/other/List.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import { connect } from 'react-redux'; 5 | import { compose } from 'recompose'; 6 | import sortBy from 'lodash/sortBy'; 7 | 8 | import { withSubscribe } from '../../hoc'; 9 | import { makeSelectTrack, subscribe, unsubscribe } from '../../modules/track'; 10 | import { makeSelectAuth } from '../../modules/auth'; 11 | import { Track } from './components/Track'; 12 | 13 | class List extends Component { 14 | render() { 15 | const { data, role, useId } = this.props; 16 | return ( 17 |
18 |
19 | {data.map(track => ( 20 | 21 | )).reverse()} 22 |
23 |
24 | ); 25 | } 26 | } 27 | 28 | List.propTypes = { 29 | loading: PropTypes.bool.isRequired, 30 | data: PropTypes.array.isRequired, 31 | dispatch: PropTypes.func.isRequired, 32 | match: PropTypes.object.isRequired, 33 | role: PropTypes.string, 34 | useId: PropTypes.string.isRequired 35 | }; 36 | 37 | List.defaultProps = { 38 | role: 'user' 39 | }; 40 | 41 | /** 42 | * Filter tracks by the user role 43 | * - Admin has full access to all tracks 44 | * - Creators can't see all pending tracks created by other creators 45 | * - Normal users can only see public tracks 46 | * @param {State} state 47 | */ 48 | const mapStateToProps = state => { 49 | const { loading, data } = makeSelectTrack(state); 50 | 51 | const { 52 | user: { role, id } 53 | } = makeSelectAuth(state); 54 | 55 | let preData; 56 | if (role === 'admin') { 57 | preData = data; 58 | } else if (role === 'creator') { 59 | preData = data.filter(track => track.isPublic || track.author === id) 60 | } else { 61 | preData = data.filter(track => track.isPublic) 62 | } 63 | 64 | return { 65 | loading, 66 | data: sortBy(preData, 'timestamp'), 67 | role, 68 | useId: id 69 | }; 70 | }; 71 | 72 | const mapDispatchToProps = dispatch => ({ dispatch }); 73 | 74 | const withReducer = connect( 75 | mapStateToProps, 76 | mapDispatchToProps 77 | ); 78 | const withData = withSubscribe(subscribe, unsubscribe); 79 | 80 | export default compose( 81 | withReducer, 82 | withData 83 | )(List); 84 | -------------------------------------------------------------------------------- /src/pages/other/NewTrack.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import moment from 'moment'; 5 | import { compose } from 'redux'; 6 | import isEmpty from 'lodash/isEmpty'; 7 | import { makeSelectAuth } from '../../modules/auth'; 8 | import { removeService } from '../../modules/track/service'; 9 | import { 10 | defaultPropsData, 11 | handleSubmit, 12 | withData, 13 | withLoading 14 | } from '../../hoc'; 15 | import queryString from '../../helpers/query-string'; 16 | 17 | import Upload from '../../components/Upload'; 18 | 19 | import { connect } from 'react-redux'; 20 | import { REMOVED } from '../../modules/track/constants'; 21 | 22 | const initialValues = { 23 | name: '', 24 | location: '', 25 | latitude: '', 26 | longitude: '', 27 | tags: '', 28 | image: '', 29 | file: '', 30 | bio: '' 31 | }; 32 | 33 | class NewTrack extends Component { 34 | constructor(props) { 35 | super(props); 36 | const track = this.props.data; 37 | this.state = { 38 | track: isEmpty(track) ? 39 | initialValues : 40 | { 41 | ...track, 42 | recorded: moment(track.recorded.toDate()).format('YYYY-MM-DD') 43 | }, 44 | errors: {} 45 | }; 46 | this.handleSubmit = this.handleSubmit.bind(this); 47 | this.handleChange = this.handleChange.bind(this); 48 | this.handleClear = this.handleClear.bind(this); 49 | this.handleDelete = this.handleDelete.bind(this); 50 | this.handleAudioDuration = this.handleAudioDuration.bind(this); 51 | } 52 | 53 | componentDidMount() { 54 | const { user } = this.props; 55 | const track = this.props.data; 56 | if (user.role === 'creator' && track.isPublic) { 57 | alert('Track approved, you can not edit content.'); 58 | } 59 | } 60 | 61 | handleSubmit(e) { 62 | e.preventDefault(); 63 | 64 | const { user } = this.props; 65 | const { track } = this.state; 66 | 67 | if (user.role === 'creator' && track.isPublic) { 68 | alert('Track approved, you can not edit content.'); 69 | return; 70 | } 71 | 72 | this.props.updatePending(true); 73 | const { search } = this.props.location; 74 | const { id } = queryString.parse(search); 75 | const errors = this.validate(track); 76 | 77 | if (isEmpty(errors)) { 78 | let preData = track; 79 | if (!id) { 80 | preData = { 81 | isPublic: false, 82 | author: user.id, 83 | authorName: user.name ? user.name : '', 84 | plays: 0, 85 | duration: '', 86 | ...track, 87 | recorded: moment(track.recorded).toDate(), 88 | timestamp: new Date(), 89 | }; 90 | } else { 91 | preData.recorded = moment(track.recorded).toDate(); 92 | } 93 | 94 | this.props 95 | .handleSave(id, preData) 96 | .then(() => { 97 | alert('Save successfully!'); 98 | this.props.history.push('/'); 99 | }) 100 | .catch(error => { 101 | alert(error.message); 102 | this.props.updatePending(false); 103 | }); 104 | } else { 105 | this.setState({ 106 | errors 107 | }); 108 | } 109 | } 110 | 111 | handleAudioDuration(e) { 112 | const duration = e.target.duration; 113 | const transformed = moment.utc(moment.duration(duration, 'second').asMilliseconds()); 114 | let durationString; 115 | 116 | if (transformed.hour()) { 117 | durationString = transformed.format('HH:m:ss'); 118 | } else { 119 | durationString = transformed.format('m:ss'); 120 | } 121 | 122 | this.setState({ 123 | ...this.state, 124 | track: { 125 | ...this.state.track, 126 | duration: durationString 127 | } 128 | }) 129 | } 130 | 131 | transformValue(name, value) { 132 | if (name === "latitude" || name === "longitude") { 133 | return parseFloat(value); 134 | } 135 | return value; 136 | } 137 | 138 | handleChange(e) { 139 | const { value, name } = e.target; 140 | const { track } = this.state; 141 | this.setState({ 142 | track: { ...track, [name]: this.transformValue(name, value) } 143 | }); 144 | } 145 | 146 | handleClear() { 147 | this.setState({ 148 | track: initialValues 149 | }); 150 | } 151 | 152 | handleDelete() { 153 | const { track } = this.state; 154 | removeService(track.id) 155 | .then(() => { 156 | this.props.dispatch({ type: REMOVED, payload: { id: track.id } }); 157 | this.props.history.push('/'); 158 | alert('Remove successfully!'); 159 | }) 160 | .catch(error => { 161 | alert(error.message); 162 | }); 163 | } 164 | validate(data) { 165 | let errors = {}; 166 | if (!data.name) { 167 | errors = { ...errors, name: 'Name is required!' }; 168 | } 169 | if (!data.location) { 170 | errors = { ...errors, location: 'Location is required!' }; 171 | } 172 | if (!data.latitude) { 173 | errors = { ...errors, latitude: 'Latitude is required!' }; 174 | } 175 | if (!data.longitude) { 176 | errors = { ...errors, longitude: 'Longitude is required!' }; 177 | } 178 | if (!data.image) { 179 | errors = { ...errors, image: 'Image is required!' }; 180 | } 181 | if (!data.file) { 182 | errors = { ...errors, file: 'Audio is required!' }; 183 | } 184 | if (!data.recorded) { 185 | errors = { ...errors, recorded: 'Recorded date is required!' }; 186 | } 187 | return errors; 188 | } 189 | showButtonDelete() { 190 | const { track } = this.state; 191 | const { user } = this.props; 192 | if (!track.id) { 193 | return null; 194 | } else { 195 | if ( 196 | user.role === 'admin' || 197 | (user.role === 'creator' && !track.isPublic) 198 | ) { 199 | return ( 200 | 206 | ); 207 | } 208 | return null; 209 | } 210 | } 211 | render() { 212 | const { track, errors } = this.state; 213 | return ( 214 |
215 |
216 |
217 |
218 | 219 | {track.id ? 'Edit track' : 'New Track'} 220 | 221 | 228 | {errors.name && {errors.name}} 229 | 236 | {errors.location && ( 237 | {errors.location} 238 | )} 239 |
240 | 250 | {errors.latitude && ( 251 | {errors.latitude} 252 | )} 253 | 263 | {errors.longitude && ( 264 | {errors.longitude} 265 | )} 266 |
267 |