├── .babelrc ├── .firebaserc ├── .gitignore ├── LICENSE.md ├── README.md ├── database.rules.json ├── firebase.json ├── images ├── logo.png ├── logo_small.png ├── logo_white.png ├── logo_white_small.png ├── step_create.png ├── step_input.png ├── step_stop.png └── step_timer.png ├── package.json ├── public ├── favicon.png ├── fonts.css ├── index.html ├── logo_big.png ├── material.min.css ├── material.min.js └── roast_profile.png ├── scss └── app.scss ├── src ├── actions.js ├── app.jsx ├── auth.js ├── components │ ├── App.jsx │ ├── Dialog.jsx │ ├── Header.jsx │ ├── Home.jsx │ ├── Instructions.jsx │ ├── LoginForm.jsx │ ├── Main.jsx │ ├── NewRoastForm.jsx │ ├── PostRoastNoteForm.jsx │ ├── RoastChart.jsx │ ├── RoastList.jsx │ ├── RoastPointInput.jsx │ ├── RoastPointsList.jsx │ ├── RoastProfile.jsx │ ├── Settings.jsx │ ├── Spinner.jsx │ ├── StopWatch.jsx │ ├── UnitSwitcher.jsx │ └── utils │ │ ├── Button.jsx │ │ ├── Card.jsx │ │ ├── CardAction.jsx │ │ ├── CardContent.jsx │ │ ├── CardTitle.jsx │ │ ├── NavigationLink.jsx │ │ ├── Radio.jsx │ │ └── Table.jsx ├── constants.js ├── containers │ ├── AppContainer.js │ ├── DialogContainer.js │ ├── DrawerContainer.js │ ├── HeaderContainer.js │ ├── LoginFormContainer.js │ ├── MainContainer.js │ ├── NewRoastFormContainer.js │ ├── PostRoastNoteFormContainer.js │ ├── RoastListContainer.js │ ├── RoastPointInputContainer.js │ ├── RoastPointsListContainer.js │ ├── RoastProfileContainer.js │ ├── SettingsContainer.js │ ├── StopWatchContainer.js │ └── UnitSwitcherContainer.js ├── demoData.js ├── helpers.js ├── history.js └── reducers │ ├── authReducer.js │ ├── dataLoadingReducer.js │ ├── dialogReducer.js │ ├── editingFieldReducer.js │ ├── index.js │ ├── newRoastReducer.js │ ├── roastInProgressReducer.js │ ├── roastsReducer.js │ ├── settingsReducer.js │ └── stopWatchReducer.js └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "react"], 3 | "plugins": ["transform-object-rest-spread"] 4 | } -------------------------------------------------------------------------------- /.firebaserc: -------------------------------------------------------------------------------- 1 | { 2 | "projects": { 3 | "default": "bobonroast" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | npm-debug.log 3 | firebase-debug.log 4 | 5 | bower_components 6 | node_modules 7 | 8 | public/bundle.js 9 | public/common.js 10 | public/bundle.js.map 11 | public/common.js.map 12 | 13 | */npm-debug.log 14 | */**/npm-debug.log 15 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The GNU General Public License, Version 2, June 1991 (GPLv2) 2 | ============================================================ 3 | 4 | > Copyright (C) 1989, 1991 Free Software Foundation, Inc. 5 | > 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA 6 | 7 | Everyone is permitted to copy and distribute verbatim copies of this license 8 | document, but changing it is not allowed. 9 | 10 | 11 | Preamble 12 | -------- 13 | 14 | The licenses for most software are designed to take away your freedom to share 15 | and change it. By contrast, the GNU General Public License is intended to 16 | guarantee your freedom to share and change free software--to make sure the 17 | software is free for all its users. This General Public License applies to most 18 | of the Free Software Foundation's software and to any other program whose 19 | authors commit to using it. (Some other Free Software Foundation software is 20 | covered by the GNU Lesser General Public License instead.) You can apply it to 21 | your programs, too. 22 | 23 | When we speak of free software, we are referring to freedom, not price. Our 24 | General Public Licenses are designed to make sure that you have the freedom to 25 | distribute copies of free software (and charge for this service if you wish), 26 | that you receive source code or can get it if you want it, that you can change 27 | the software or use pieces of it in new free programs; and that you know you can 28 | do these things. 29 | 30 | To protect your rights, we need to make restrictions that forbid anyone to deny 31 | you these rights or to ask you to surrender the rights. These restrictions 32 | translate to certain responsibilities for you if you distribute copies of the 33 | software, or if you modify it. 34 | 35 | For example, if you distribute copies of such a program, whether gratis or for a 36 | fee, you must give the recipients all the rights that you have. You must make 37 | sure that they, too, receive or can get the source code. And you must show them 38 | these terms so they know their rights. 39 | 40 | We protect your rights with two steps: (1) copyright the software, and (2) offer 41 | you this license which gives you legal permission to copy, distribute and/or 42 | modify the software. 43 | 44 | Also, for each author's protection and ours, we want to make certain that 45 | everyone understands that there is no warranty for this free software. If the 46 | software is modified by someone else and passed on, we want its recipients to 47 | know that what they have is not the original, so that any problems introduced by 48 | others will not reflect on the original authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software patents. We wish 51 | to avoid the danger that redistributors of a free program will individually 52 | obtain patent licenses, in effect making the program proprietary. To prevent 53 | this, we have made it clear that any patent must be licensed for everyone's free 54 | use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and modification 57 | follow. 58 | 59 | 60 | Terms And Conditions For Copying, Distribution And Modification 61 | --------------------------------------------------------------- 62 | 63 | **0.** This License applies to any program or other work which contains a notice 64 | placed by the copyright holder saying it may be distributed under the terms of 65 | this General Public License. The "Program", below, refers to any such program or 66 | work, and a "work based on the Program" means either the Program or any 67 | derivative work under copyright law: that is to say, a work containing the 68 | Program or a portion of it, either verbatim or with modifications and/or 69 | translated into another language. (Hereinafter, translation is included without 70 | limitation in the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not covered by 73 | this License; they are outside its scope. The act of running the Program is not 74 | restricted, and the output from the Program is covered only if its contents 75 | constitute a work based on the Program (independent of having been made by 76 | running the Program). Whether that is true depends on what the Program does. 77 | 78 | **1.** You may copy and distribute verbatim copies of the Program's source code 79 | as you receive it, in any medium, provided that you conspicuously and 80 | appropriately publish on each copy an appropriate copyright notice and 81 | disclaimer of warranty; keep intact all the notices that refer to this License 82 | and to the absence of any warranty; and give any other recipients of the Program 83 | a copy of this License along with the Program. 84 | 85 | You may charge a fee for the physical act of transferring a copy, and you may at 86 | your option offer warranty protection in exchange for a fee. 87 | 88 | **2.** You may modify your copy or copies of the Program or any portion of it, 89 | thus forming a work based on the Program, and copy and distribute such 90 | modifications or work under the terms of Section 1 above, provided that you also 91 | meet all of these conditions: 92 | 93 | * **a)** You must cause the modified files to carry prominent notices stating 94 | that you changed the files and the date of any change. 95 | 96 | * **b)** You must cause any work that you distribute or publish, that in whole 97 | or in part contains or is derived from the Program or any part thereof, to 98 | be licensed as a whole at no charge to all third parties under the terms of 99 | this License. 100 | 101 | * **c)** If the modified program normally reads commands interactively when 102 | run, you must cause it, when started running for such interactive use in the 103 | most ordinary way, to print or display an announcement including an 104 | appropriate copyright notice and a notice that there is no warranty (or 105 | else, saying that you provide a warranty) and that users may redistribute 106 | the program under these conditions, and telling the user how to view a copy 107 | of this License. (Exception: if the Program itself is interactive but does 108 | not normally print such an announcement, your work based on the Program is 109 | not required to print an announcement.) 110 | 111 | These requirements apply to the modified work as a whole. If identifiable 112 | sections of that work are not derived from the Program, and can be reasonably 113 | considered independent and separate works in themselves, then this License, and 114 | its terms, do not apply to those sections when you distribute them as separate 115 | works. But when you distribute the same sections as part of a whole which is a 116 | work based on the Program, the distribution of the whole must be on the terms of 117 | this License, whose permissions for other licensees extend to the entire whole, 118 | and thus to each and every part regardless of who wrote it. 119 | 120 | Thus, it is not the intent of this section to claim rights or contest your 121 | rights to work written entirely by you; rather, the intent is to exercise the 122 | right to control the distribution of derivative or collective works based on the 123 | Program. 124 | 125 | In addition, mere aggregation of another work not based on the Program with the 126 | Program (or with a work based on the Program) on a volume of a storage or 127 | distribution medium does not bring the other work under the scope of this 128 | License. 129 | 130 | **3.** You may copy and distribute the Program (or a work based on it, under 131 | Section 2) in object code or executable form under the terms of Sections 1 and 2 132 | above provided that you also do one of the following: 133 | 134 | * **a)** Accompany it with the complete corresponding machine-readable source 135 | code, which must be distributed under the terms of Sections 1 and 2 above on 136 | a medium customarily used for software interchange; or, 137 | 138 | * **b)** Accompany it with a written offer, valid for at least three years, to 139 | give any third party, for a charge no more than your cost of physically 140 | performing source distribution, a complete machine-readable copy of the 141 | corresponding source code, to be distributed under the terms of Sections 1 142 | and 2 above on a medium customarily used for software interchange; or, 143 | 144 | * **c)** Accompany it with the information you received as to the offer to 145 | distribute corresponding source code. (This alternative is allowed only for 146 | noncommercial distribution and only if you received the program in object 147 | code or executable form with such an offer, in accord with Subsection b 148 | above.) 149 | 150 | The source code for a work means the preferred form of the work for making 151 | modifications to it. For an executable work, complete source code means all the 152 | source code for all modules it contains, plus any associated interface 153 | definition files, plus the scripts used to control compilation and installation 154 | of the executable. However, as a special exception, the source code distributed 155 | need not include anything that is normally distributed (in either source or 156 | binary form) with the major components (compiler, kernel, and so on) of the 157 | operating system on which the executable runs, unless that component itself 158 | accompanies the executable. 159 | 160 | If distribution of executable or object code is made by offering access to copy 161 | from a designated place, then offering equivalent access to copy the source code 162 | from the same place counts as distribution of the source code, even though third 163 | parties are not compelled to copy the source along with the object code. 164 | 165 | **4.** You may not copy, modify, sublicense, or distribute the Program except as 166 | expressly provided under this License. Any attempt otherwise to copy, modify, 167 | sublicense or distribute the Program is void, and will automatically terminate 168 | your rights under this License. However, parties who have received copies, or 169 | rights, from you under this License will not have their licenses terminated so 170 | long as such parties remain in full compliance. 171 | 172 | **5.** You are not required to accept this License, since you have not signed 173 | it. However, nothing else grants you permission to modify or distribute the 174 | Program or its derivative works. These actions are prohibited by law if you do 175 | not accept this License. Therefore, by modifying or distributing the Program (or 176 | any work based on the Program), you indicate your acceptance of this License to 177 | do so, and all its terms and conditions for copying, distributing or modifying 178 | the Program or works based on it. 179 | 180 | **6.** Each time you redistribute the Program (or any work based on the 181 | Program), the recipient automatically receives a license from the original 182 | licensor to copy, distribute or modify the Program subject to these terms and 183 | conditions. You may not impose any further restrictions on the recipients' 184 | exercise of the rights granted herein. You are not responsible for enforcing 185 | compliance by third parties to this License. 186 | 187 | **7.** If, as a consequence of a court judgment or allegation of patent 188 | infringement or for any other reason (not limited to patent issues), conditions 189 | are imposed on you (whether by court order, agreement or otherwise) that 190 | contradict the conditions of this License, they do not excuse you from the 191 | conditions of this License. If you cannot distribute so as to satisfy 192 | simultaneously your obligations under this License and any other pertinent 193 | obligations, then as a consequence you may not distribute the Program at all. 194 | For example, if a patent license would not permit royalty-free redistribution of 195 | the Program by all those who receive copies directly or indirectly through you, 196 | then the only way you could satisfy both it and this License would be to refrain 197 | entirely from distribution of the Program. 198 | 199 | If any portion of this section is held invalid or unenforceable under any 200 | particular circumstance, the balance of the section is intended to apply and the 201 | section as a whole is intended to apply in other circumstances. 202 | 203 | It is not the purpose of this section to induce you to infringe any patents or 204 | other property right claims or to contest validity of any such claims; this 205 | section has the sole purpose of protecting the integrity of the free software 206 | distribution system, which is implemented by public license practices. Many 207 | people have made generous contributions to the wide range of software 208 | distributed through that system in reliance on consistent application of that 209 | system; it is up to the author/donor to decide if he or she is willing to 210 | distribute software through any other system and a licensee cannot impose that 211 | choice. 212 | 213 | This section is intended to make thoroughly clear what is believed to be a 214 | consequence of the rest of this License. 215 | 216 | **8.** If the distribution and/or use of the Program is restricted in certain 217 | countries either by patents or by copyrighted interfaces, the original copyright 218 | holder who places the Program under this License may add an explicit 219 | geographical distribution limitation excluding those countries, so that 220 | distribution is permitted only in or among countries not thus excluded. In such 221 | case, this License incorporates the limitation as if written in the body of this 222 | License. 223 | 224 | **9.** The Free Software Foundation may publish revised and/or new versions of 225 | the General Public License from time to time. Such new versions will be similar 226 | in spirit to the present version, but may differ in detail to address new 227 | problems or concerns. 228 | 229 | Each version is given a distinguishing version number. If the Program specifies 230 | a version number of this License which applies to it and "any later version", 231 | you have the option of following the terms and conditions either of that version 232 | or of any later version published by the Free Software Foundation. If the 233 | Program does not specify a version number of this License, you may choose any 234 | version ever published by the Free Software Foundation. 235 | 236 | **10.** If you wish to incorporate parts of the Program into other free programs 237 | whose distribution conditions are different, write to the author to ask for 238 | permission. For software which is copyrighted by the Free Software Foundation, 239 | write to the Free Software Foundation; we sometimes make exceptions for this. 240 | Our decision will be guided by the two goals of preserving the free status of 241 | all derivatives of our free software and of promoting the sharing and reuse of 242 | software generally. 243 | 244 | 245 | No Warranty 246 | ----------- 247 | 248 | **11.** BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR 249 | THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE 250 | STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM 251 | "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, 252 | BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A 253 | PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE 254 | PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 255 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 256 | 257 | **12.** IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 258 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE 259 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 260 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR 261 | INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA 262 | BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A 263 | FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER 264 | OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 265 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # bobonroast 2 | 3 | > A place to record and keep track of your coffee roast profiles in real-time. 4 | 5 | This is the codebase for [Bobon Profiles](https://roast.bobon.coffee) 6 | 7 | ## Prerequisite 8 | 9 | Bobon Profiles is a React - Redux app built on top of a Firebase database. 10 | 11 | ### If you are developing a separate project from this (using a different database) 12 | 13 | Create your own Firebase project. After that, update FIREBASE constant in `src/constants.js` with your own info. 14 | 15 | Enable Google and Facebook authentication for the Firebase App. 16 | 17 | ## Development 18 | 19 | ```sh 20 | $ npm install 21 | $ npm run watch 22 | ``` 23 | 24 | After that, access the site at https://localhost:8080 (IMPORTANT: HTTPS, not HTTP - so we can use Firebase redirect authentication with Facebook and Google from the development site) 25 | 26 | ## Contributing 27 | 28 | Pull requests and stars are always welcome. For bugs and feature requests, [please create an issue](https://github.com/nathandao/bobonroastprofile/issues) 29 | 30 | ## Author 31 | 32 | **Nathan Dao** 33 | 34 | * [github/nathandao](https://github.com/nathandao) 35 | * [twitter/nathan_dao](http://twitter.com/nathan_dao) 36 | 37 | ## License 38 | 39 | Bobon Profiles is distributed under GPLv2 license. TLDR: 40 | 41 | You MUST: 42 | 43 | - Include the original code and where to get Bobon Roast 44 | - Opensource your project 45 | - Include Bobon Roast's copyright 46 | - State changes made to Bobon Roast 47 | - Include the GPLv2 lincense in your source code 48 | 49 | You can: 50 | 51 | - Commercial use 52 | - Modify 53 | - Distribute 54 | - Place Warranty 55 | 56 | You cannot: 57 | 58 | - Sublicense 59 | - Hold liable 60 | 61 | 62 | Copyright © 2016 [Nathan Dao](https://guynathan.com) 63 | Licensed under the GPLv2 license. 64 | -------------------------------------------------------------------------------- /database.rules.json: -------------------------------------------------------------------------------- 1 | {"rules": 2 | { 3 | "roasts": { 4 | "$uid": { 5 | ".write": "auth !== null", 6 | ".read": "$uid === auth.uid", 7 | "$roast": { 8 | ".read": "data.child('uid').val() === auth.uid", 9 | ".write": "data.child('uid').val() === auth.uid" 10 | } 11 | } 12 | }, 13 | 14 | "settings": { 15 | "$uid": { 16 | ".write": "$uid === auth.uid", 17 | ".read": "$uid === auth.uid" 18 | } 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "database": { 3 | "rules": "database.rules.json" 4 | }, 5 | "hosting": { 6 | "public": "public", 7 | "rewrites": [ 8 | { 9 | "source": "**", 10 | "destination": "/index.html" 11 | } 12 | ] 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/project-bobon/bobonroastprofile/d70af19cdd0f49cfdc12ebed344a621890176cd5/images/logo.png -------------------------------------------------------------------------------- /images/logo_small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/project-bobon/bobonroastprofile/d70af19cdd0f49cfdc12ebed344a621890176cd5/images/logo_small.png -------------------------------------------------------------------------------- /images/logo_white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/project-bobon/bobonroastprofile/d70af19cdd0f49cfdc12ebed344a621890176cd5/images/logo_white.png -------------------------------------------------------------------------------- /images/logo_white_small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/project-bobon/bobonroastprofile/d70af19cdd0f49cfdc12ebed344a621890176cd5/images/logo_white_small.png -------------------------------------------------------------------------------- /images/step_create.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/project-bobon/bobonroastprofile/d70af19cdd0f49cfdc12ebed344a621890176cd5/images/step_create.png -------------------------------------------------------------------------------- /images/step_input.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/project-bobon/bobonroastprofile/d70af19cdd0f49cfdc12ebed344a621890176cd5/images/step_input.png -------------------------------------------------------------------------------- /images/step_stop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/project-bobon/bobonroastprofile/d70af19cdd0f49cfdc12ebed344a621890176cd5/images/step_stop.png -------------------------------------------------------------------------------- /images/step_timer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/project-bobon/bobonroastprofile/d70af19cdd0f49cfdc12ebed344a621890176cd5/images/step_timer.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bobonroast", 3 | "version": "0.1.0", 4 | "description": "A place to record and keep track of your coffee roast profiles", 5 | "main": "index.js", 6 | "bin": { 7 | "webpack-dev-server": "./webpack-dev-server.js", 8 | "webpack": "./webpack.js" 9 | }, 10 | "scripts": { 11 | "watch": "webpack-dev-server --https --hot --inline --progress --colors --content-base public", 12 | "build": "webpack --progress --colors -p", 13 | "deploy": "webpack --progress --colors -p && firebase deploy" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/project-bobon/bobonroastprofile.git" 18 | }, 19 | "author": "Nathan Dao", 20 | "license": "GPLv2", 21 | "bugs": { 22 | "url": "https://github.com/project-bobon/bobonroastprofile/issues" 23 | }, 24 | "homepage": "https://github.com/project-bobon/bobonroastprofile#readme", 25 | "devDependencies": { 26 | "autoprefixer-loader": "^3.2.0", 27 | "babel-core": "^6.9.1", 28 | "babel-loader": "^6.2.4", 29 | "babel-plugin-transform-object-rest-spread": "^6.8.0", 30 | "babel-preset-es2015": "^6.9.0", 31 | "babel-preset-react": "^6.5.0", 32 | "chart.js": "^2.1.4", 33 | "css-loader": "^0.23.1", 34 | "file-loader": "^0.8.5", 35 | "firebase": "^3.0.3", 36 | "html-webpack-plugin": "^2.19.0", 37 | "moment": "^2.13.0", 38 | "node-sass": "^3.7.0", 39 | "react": "^15.1.0", 40 | "react-addons-css-transition-group": "^15.1.0", 41 | "react-addons-transition-group": "^15.1.0", 42 | "react-chartist": "^0.10.2", 43 | "react-chartjs": "git://github.com/malloc-fi/react-chartjs.git", 44 | "react-dom": "^15.1.0", 45 | "react-ga": "^2.1.0", 46 | "react-redux": "^4.4.5", 47 | "react-router": "^2.4.1", 48 | "redux": "^3.5.2", 49 | "redux-thunk": "^2.1.0", 50 | "sass-loader": "^3.2.0", 51 | "source-map-loader": "^0.1.5", 52 | "style-loader": "^0.13.1", 53 | "url-loader": "^0.5.7", 54 | "webpack": "^1.13.1", 55 | "webpack-dev-server": "^1.14.1" 56 | }, 57 | "dependencies": {} 58 | } 59 | -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/project-bobon/bobonroastprofile/d70af19cdd0f49cfdc12ebed344a621890176cd5/public/favicon.png -------------------------------------------------------------------------------- /public/fonts.css: -------------------------------------------------------------------------------- 1 | /* cyrillic-ext */ 2 | @font-face { 3 | font-family: 'Roboto'; 4 | font-style: normal; 5 | font-weight: 100; 6 | src: local('Roboto Thin'), local('Roboto-Thin'), url(https://fonts.gstatic.com/s/roboto/v15/ty9dfvLAziwdqQ2dHoyjphkAz4rYn47Zy2rvigWQf6w.woff2) format('woff2'); 7 | unicode-range: U+0460-052F, U+20B4, U+2DE0-2DFF, U+A640-A69F; 8 | } 9 | /* cyrillic */ 10 | @font-face { 11 | font-family: 'Roboto'; 12 | font-style: normal; 13 | font-weight: 100; 14 | src: local('Roboto Thin'), local('Roboto-Thin'), url(https://fonts.gstatic.com/s/roboto/v15/frNV30OaYdlFRtH2VnZZdhkAz4rYn47Zy2rvigWQf6w.woff2) format('woff2'); 15 | unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; 16 | } 17 | /* greek-ext */ 18 | @font-face { 19 | font-family: 'Roboto'; 20 | font-style: normal; 21 | font-weight: 100; 22 | src: local('Roboto Thin'), local('Roboto-Thin'), url(https://fonts.gstatic.com/s/roboto/v15/gwVJDERN2Amz39wrSoZ7FxkAz4rYn47Zy2rvigWQf6w.woff2) format('woff2'); 23 | unicode-range: U+1F00-1FFF; 24 | } 25 | /* greek */ 26 | @font-face { 27 | font-family: 'Roboto'; 28 | font-style: normal; 29 | font-weight: 100; 30 | src: local('Roboto Thin'), local('Roboto-Thin'), url(https://fonts.gstatic.com/s/roboto/v15/aZMswpodYeVhtRvuABJWvBkAz4rYn47Zy2rvigWQf6w.woff2) format('woff2'); 31 | unicode-range: U+0370-03FF; 32 | } 33 | /* vietnamese */ 34 | @font-face { 35 | font-family: 'Roboto'; 36 | font-style: normal; 37 | font-weight: 100; 38 | src: local('Roboto Thin'), local('Roboto-Thin'), url(https://fonts.gstatic.com/s/roboto/v15/VvXUGKZXbHtX_S_VCTLpGhkAz4rYn47Zy2rvigWQf6w.woff2) format('woff2'); 39 | unicode-range: U+0102-0103, U+1EA0-1EF9, U+20AB; 40 | } 41 | /* latin-ext */ 42 | @font-face { 43 | font-family: 'Roboto'; 44 | font-style: normal; 45 | font-weight: 100; 46 | src: local('Roboto Thin'), local('Roboto-Thin'), url(https://fonts.gstatic.com/s/roboto/v15/e7MeVAyvogMqFwwl61PKhBkAz4rYn47Zy2rvigWQf6w.woff2) format('woff2'); 47 | unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF; 48 | } 49 | /* latin */ 50 | @font-face { 51 | font-family: 'Roboto'; 52 | font-style: normal; 53 | font-weight: 100; 54 | src: local('Roboto Thin'), local('Roboto-Thin'), url(https://fonts.gstatic.com/s/roboto/v15/2tsd397wLxj96qwHyNIkxHYhjbSpvc47ee6xR_80Hnw.woff2) format('woff2'); 55 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000; 56 | } 57 | /* cyrillic-ext */ 58 | @font-face { 59 | font-family: 'Roboto'; 60 | font-style: normal; 61 | font-weight: 300; 62 | src: local('Roboto Light'), local('Roboto-Light'), url(https://fonts.gstatic.com/s/roboto/v15/0eC6fl06luXEYWpBSJvXCIX0hVgzZQUfRDuZrPvH3D8.woff2) format('woff2'); 63 | unicode-range: U+0460-052F, U+20B4, U+2DE0-2DFF, U+A640-A69F; 64 | } 65 | /* cyrillic */ 66 | @font-face { 67 | font-family: 'Roboto'; 68 | font-style: normal; 69 | font-weight: 300; 70 | src: local('Roboto Light'), local('Roboto-Light'), url(https://fonts.gstatic.com/s/roboto/v15/Fl4y0QdOxyyTHEGMXX8kcYX0hVgzZQUfRDuZrPvH3D8.woff2) format('woff2'); 71 | unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; 72 | } 73 | /* greek-ext */ 74 | @font-face { 75 | font-family: 'Roboto'; 76 | font-style: normal; 77 | font-weight: 300; 78 | src: local('Roboto Light'), local('Roboto-Light'), url(https://fonts.gstatic.com/s/roboto/v15/-L14Jk06m6pUHB-5mXQQnYX0hVgzZQUfRDuZrPvH3D8.woff2) format('woff2'); 79 | unicode-range: U+1F00-1FFF; 80 | } 81 | /* greek */ 82 | @font-face { 83 | font-family: 'Roboto'; 84 | font-style: normal; 85 | font-weight: 300; 86 | src: local('Roboto Light'), local('Roboto-Light'), url(https://fonts.gstatic.com/s/roboto/v15/I3S1wsgSg9YCurV6PUkTOYX0hVgzZQUfRDuZrPvH3D8.woff2) format('woff2'); 87 | unicode-range: U+0370-03FF; 88 | } 89 | /* vietnamese */ 90 | @font-face { 91 | font-family: 'Roboto'; 92 | font-style: normal; 93 | font-weight: 300; 94 | src: local('Roboto Light'), local('Roboto-Light'), url(https://fonts.gstatic.com/s/roboto/v15/NYDWBdD4gIq26G5XYbHsFIX0hVgzZQUfRDuZrPvH3D8.woff2) format('woff2'); 95 | unicode-range: U+0102-0103, U+1EA0-1EF9, U+20AB; 96 | } 97 | /* latin-ext */ 98 | @font-face { 99 | font-family: 'Roboto'; 100 | font-style: normal; 101 | font-weight: 300; 102 | src: local('Roboto Light'), local('Roboto-Light'), url(https://fonts.gstatic.com/s/roboto/v15/Pru33qjShpZSmG3z6VYwnYX0hVgzZQUfRDuZrPvH3D8.woff2) format('woff2'); 103 | unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF; 104 | } 105 | /* latin */ 106 | @font-face { 107 | font-family: 'Roboto'; 108 | font-style: normal; 109 | font-weight: 300; 110 | src: local('Roboto Light'), local('Roboto-Light'), url(https://fonts.gstatic.com/s/roboto/v15/Hgo13k-tfSpn0qi1SFdUfZBw1xU1rKptJj_0jans920.woff2) format('woff2'); 111 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000; 112 | } 113 | /* cyrillic-ext */ 114 | @font-face { 115 | font-family: 'Roboto'; 116 | font-style: normal; 117 | font-weight: 400; 118 | src: local('Roboto'), local('Roboto-Regular'), url(https://fonts.gstatic.com/s/roboto/v15/sTdaA6j0Psb920Vjv-mrzH-_kf6ByYO6CLYdB4HQE-Y.woff2) format('woff2'); 119 | unicode-range: U+0460-052F, U+20B4, U+2DE0-2DFF, U+A640-A69F; 120 | } 121 | /* cyrillic */ 122 | @font-face { 123 | font-family: 'Roboto'; 124 | font-style: normal; 125 | font-weight: 400; 126 | src: local('Roboto'), local('Roboto-Regular'), url(https://fonts.gstatic.com/s/roboto/v15/uYECMKoHcO9x1wdmbyHIm3-_kf6ByYO6CLYdB4HQE-Y.woff2) format('woff2'); 127 | unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; 128 | } 129 | /* greek-ext */ 130 | @font-face { 131 | font-family: 'Roboto'; 132 | font-style: normal; 133 | font-weight: 400; 134 | src: local('Roboto'), local('Roboto-Regular'), url(https://fonts.gstatic.com/s/roboto/v15/tnj4SB6DNbdaQnsM8CFqBX-_kf6ByYO6CLYdB4HQE-Y.woff2) format('woff2'); 135 | unicode-range: U+1F00-1FFF; 136 | } 137 | /* greek */ 138 | @font-face { 139 | font-family: 'Roboto'; 140 | font-style: normal; 141 | font-weight: 400; 142 | src: local('Roboto'), local('Roboto-Regular'), url(https://fonts.gstatic.com/s/roboto/v15/_VYFx-s824kXq_Ul2BHqYH-_kf6ByYO6CLYdB4HQE-Y.woff2) format('woff2'); 143 | unicode-range: U+0370-03FF; 144 | } 145 | /* vietnamese */ 146 | @font-face { 147 | font-family: 'Roboto'; 148 | font-style: normal; 149 | font-weight: 400; 150 | src: local('Roboto'), local('Roboto-Regular'), url(https://fonts.gstatic.com/s/roboto/v15/NJ4vxlgWwWbEsv18dAhqnn-_kf6ByYO6CLYdB4HQE-Y.woff2) format('woff2'); 151 | unicode-range: U+0102-0103, U+1EA0-1EF9, U+20AB; 152 | } 153 | /* latin-ext */ 154 | @font-face { 155 | font-family: 'Roboto'; 156 | font-style: normal; 157 | font-weight: 400; 158 | src: local('Roboto'), local('Roboto-Regular'), url(https://fonts.gstatic.com/s/roboto/v15/Ks_cVxiCiwUWVsFWFA3Bjn-_kf6ByYO6CLYdB4HQE-Y.woff2) format('woff2'); 159 | unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF; 160 | } 161 | /* latin */ 162 | @font-face { 163 | font-family: 'Roboto'; 164 | font-style: normal; 165 | font-weight: 400; 166 | src: local('Roboto'), local('Roboto-Regular'), url(https://fonts.gstatic.com/s/roboto/v15/oMMgfZMQthOryQo9n22dcuvvDin1pK8aKteLpeZ5c0A.woff2) format('woff2'); 167 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000; 168 | } 169 | /* cyrillic-ext */ 170 | @font-face { 171 | font-family: 'Roboto'; 172 | font-style: normal; 173 | font-weight: 500; 174 | src: local('Roboto Medium'), local('Roboto-Medium'), url(https://fonts.gstatic.com/s/roboto/v15/ZLqKeelYbATG60EpZBSDy4X0hVgzZQUfRDuZrPvH3D8.woff2) format('woff2'); 175 | unicode-range: U+0460-052F, U+20B4, U+2DE0-2DFF, U+A640-A69F; 176 | } 177 | /* cyrillic */ 178 | @font-face { 179 | font-family: 'Roboto'; 180 | font-style: normal; 181 | font-weight: 500; 182 | src: local('Roboto Medium'), local('Roboto-Medium'), url(https://fonts.gstatic.com/s/roboto/v15/oHi30kwQWvpCWqAhzHcCSIX0hVgzZQUfRDuZrPvH3D8.woff2) format('woff2'); 183 | unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; 184 | } 185 | /* greek-ext */ 186 | @font-face { 187 | font-family: 'Roboto'; 188 | font-style: normal; 189 | font-weight: 500; 190 | src: local('Roboto Medium'), local('Roboto-Medium'), url(https://fonts.gstatic.com/s/roboto/v15/rGvHdJnr2l75qb0YND9NyIX0hVgzZQUfRDuZrPvH3D8.woff2) format('woff2'); 191 | unicode-range: U+1F00-1FFF; 192 | } 193 | /* greek */ 194 | @font-face { 195 | font-family: 'Roboto'; 196 | font-style: normal; 197 | font-weight: 500; 198 | src: local('Roboto Medium'), local('Roboto-Medium'), url(https://fonts.gstatic.com/s/roboto/v15/mx9Uck6uB63VIKFYnEMXrYX0hVgzZQUfRDuZrPvH3D8.woff2) format('woff2'); 199 | unicode-range: U+0370-03FF; 200 | } 201 | /* vietnamese */ 202 | @font-face { 203 | font-family: 'Roboto'; 204 | font-style: normal; 205 | font-weight: 500; 206 | src: local('Roboto Medium'), local('Roboto-Medium'), url(https://fonts.gstatic.com/s/roboto/v15/mbmhprMH69Zi6eEPBYVFhYX0hVgzZQUfRDuZrPvH3D8.woff2) format('woff2'); 207 | unicode-range: U+0102-0103, U+1EA0-1EF9, U+20AB; 208 | } 209 | /* latin-ext */ 210 | @font-face { 211 | font-family: 'Roboto'; 212 | font-style: normal; 213 | font-weight: 500; 214 | src: local('Roboto Medium'), local('Roboto-Medium'), url(https://fonts.gstatic.com/s/roboto/v15/oOeFwZNlrTefzLYmlVV1UIX0hVgzZQUfRDuZrPvH3D8.woff2) format('woff2'); 215 | unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF; 216 | } 217 | /* latin */ 218 | @font-face { 219 | font-family: 'Roboto'; 220 | font-style: normal; 221 | font-weight: 500; 222 | src: local('Roboto Medium'), local('Roboto-Medium'), url(https://fonts.gstatic.com/s/roboto/v15/RxZJdnzeo3R5zSexge8UUZBw1xU1rKptJj_0jans920.woff2) format('woff2'); 223 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000; 224 | } 225 | /* cyrillic-ext */ 226 | @font-face { 227 | font-family: 'Roboto'; 228 | font-style: normal; 229 | font-weight: 700; 230 | src: local('Roboto Bold'), local('Roboto-Bold'), url(https://fonts.gstatic.com/s/roboto/v15/77FXFjRbGzN4aCrSFhlh3oX0hVgzZQUfRDuZrPvH3D8.woff2) format('woff2'); 231 | unicode-range: U+0460-052F, U+20B4, U+2DE0-2DFF, U+A640-A69F; 232 | } 233 | /* cyrillic */ 234 | @font-face { 235 | font-family: 'Roboto'; 236 | font-style: normal; 237 | font-weight: 700; 238 | src: local('Roboto Bold'), local('Roboto-Bold'), url(https://fonts.gstatic.com/s/roboto/v15/isZ-wbCXNKAbnjo6_TwHToX0hVgzZQUfRDuZrPvH3D8.woff2) format('woff2'); 239 | unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; 240 | } 241 | /* greek-ext */ 242 | @font-face { 243 | font-family: 'Roboto'; 244 | font-style: normal; 245 | font-weight: 700; 246 | src: local('Roboto Bold'), local('Roboto-Bold'), url(https://fonts.gstatic.com/s/roboto/v15/UX6i4JxQDm3fVTc1CPuwqoX0hVgzZQUfRDuZrPvH3D8.woff2) format('woff2'); 247 | unicode-range: U+1F00-1FFF; 248 | } 249 | /* greek */ 250 | @font-face { 251 | font-family: 'Roboto'; 252 | font-style: normal; 253 | font-weight: 700; 254 | src: local('Roboto Bold'), local('Roboto-Bold'), url(https://fonts.gstatic.com/s/roboto/v15/jSN2CGVDbcVyCnfJfjSdfIX0hVgzZQUfRDuZrPvH3D8.woff2) format('woff2'); 255 | unicode-range: U+0370-03FF; 256 | } 257 | /* vietnamese */ 258 | @font-face { 259 | font-family: 'Roboto'; 260 | font-style: normal; 261 | font-weight: 700; 262 | src: local('Roboto Bold'), local('Roboto-Bold'), url(https://fonts.gstatic.com/s/roboto/v15/PwZc-YbIL414wB9rB1IAPYX0hVgzZQUfRDuZrPvH3D8.woff2) format('woff2'); 263 | unicode-range: U+0102-0103, U+1EA0-1EF9, U+20AB; 264 | } 265 | /* latin-ext */ 266 | @font-face { 267 | font-family: 'Roboto'; 268 | font-style: normal; 269 | font-weight: 700; 270 | src: local('Roboto Bold'), local('Roboto-Bold'), url(https://fonts.gstatic.com/s/roboto/v15/97uahxiqZRoncBaCEI3aW4X0hVgzZQUfRDuZrPvH3D8.woff2) format('woff2'); 271 | unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF; 272 | } 273 | /* latin */ 274 | @font-face { 275 | font-family: 'Roboto'; 276 | font-style: normal; 277 | font-weight: 700; 278 | src: local('Roboto Bold'), local('Roboto-Bold'), url(https://fonts.gstatic.com/s/roboto/v15/d-6IYplOFocCacKzxwXSOJBw1xU1rKptJj_0jans920.woff2) format('woff2'); 279 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000; 280 | } 281 | /* cyrillic-ext */ 282 | @font-face { 283 | font-family: 'Roboto'; 284 | font-style: normal; 285 | font-weight: 900; 286 | src: local('Roboto Black'), local('Roboto-Black'), url(https://fonts.gstatic.com/s/roboto/v15/s7gftie1JANC-QmDJvMWZoX0hVgzZQUfRDuZrPvH3D8.woff2) format('woff2'); 287 | unicode-range: U+0460-052F, U+20B4, U+2DE0-2DFF, U+A640-A69F; 288 | } 289 | /* cyrillic */ 290 | @font-face { 291 | font-family: 'Roboto'; 292 | font-style: normal; 293 | font-weight: 900; 294 | src: local('Roboto Black'), local('Roboto-Black'), url(https://fonts.gstatic.com/s/roboto/v15/3Y_xCyt7TNunMGg0Et2pnoX0hVgzZQUfRDuZrPvH3D8.woff2) format('woff2'); 295 | unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; 296 | } 297 | /* greek-ext */ 298 | @font-face { 299 | font-family: 'Roboto'; 300 | font-style: normal; 301 | font-weight: 900; 302 | src: local('Roboto Black'), local('Roboto-Black'), url(https://fonts.gstatic.com/s/roboto/v15/WeQRRE07FDkIrr29oHQgHIX0hVgzZQUfRDuZrPvH3D8.woff2) format('woff2'); 303 | unicode-range: U+1F00-1FFF; 304 | } 305 | /* greek */ 306 | @font-face { 307 | font-family: 'Roboto'; 308 | font-style: normal; 309 | font-weight: 900; 310 | src: local('Roboto Black'), local('Roboto-Black'), url(https://fonts.gstatic.com/s/roboto/v15/jyIYROCkJM3gZ4KV00YXOIX0hVgzZQUfRDuZrPvH3D8.woff2) format('woff2'); 311 | unicode-range: U+0370-03FF; 312 | } 313 | /* vietnamese */ 314 | @font-face { 315 | font-family: 'Roboto'; 316 | font-style: normal; 317 | font-weight: 900; 318 | src: local('Roboto Black'), local('Roboto-Black'), url(https://fonts.gstatic.com/s/roboto/v15/phsu-QZXz1JBv0PbFoPmEIX0hVgzZQUfRDuZrPvH3D8.woff2) format('woff2'); 319 | unicode-range: U+0102-0103, U+1EA0-1EF9, U+20AB; 320 | } 321 | /* latin-ext */ 322 | @font-face { 323 | font-family: 'Roboto'; 324 | font-style: normal; 325 | font-weight: 900; 326 | src: local('Roboto Black'), local('Roboto-Black'), url(https://fonts.gstatic.com/s/roboto/v15/9_7S_tWeGDh5Pq3u05RVkoX0hVgzZQUfRDuZrPvH3D8.woff2) format('woff2'); 327 | unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF; 328 | } 329 | /* latin */ 330 | @font-face { 331 | font-family: 'Roboto'; 332 | font-style: normal; 333 | font-weight: 900; 334 | src: local('Roboto Black'), local('Roboto-Black'), url(https://fonts.gstatic.com/s/roboto/v15/mnpfi9pxYH-Go5UiibESIpBw1xU1rKptJj_0jans920.woff2) format('woff2'); 335 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000; 336 | } 337 | /* cyrillic-ext */ 338 | @font-face { 339 | font-family: 'Roboto'; 340 | font-style: italic; 341 | font-weight: 400; 342 | src: local('Roboto Italic'), local('Roboto-Italic'), url(https://fonts.gstatic.com/s/roboto/v15/WxrXJa0C3KdtC7lMafG4dRkAz4rYn47Zy2rvigWQf6w.woff2) format('woff2'); 343 | unicode-range: U+0460-052F, U+20B4, U+2DE0-2DFF, U+A640-A69F; 344 | } 345 | /* cyrillic */ 346 | @font-face { 347 | font-family: 'Roboto'; 348 | font-style: italic; 349 | font-weight: 400; 350 | src: local('Roboto Italic'), local('Roboto-Italic'), url(https://fonts.gstatic.com/s/roboto/v15/OpXUqTo0UgQQhGj_SFdLWBkAz4rYn47Zy2rvigWQf6w.woff2) format('woff2'); 351 | unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; 352 | } 353 | /* greek-ext */ 354 | @font-face { 355 | font-family: 'Roboto'; 356 | font-style: italic; 357 | font-weight: 400; 358 | src: local('Roboto Italic'), local('Roboto-Italic'), url(https://fonts.gstatic.com/s/roboto/v15/1hZf02POANh32k2VkgEoUBkAz4rYn47Zy2rvigWQf6w.woff2) format('woff2'); 359 | unicode-range: U+1F00-1FFF; 360 | } 361 | /* greek */ 362 | @font-face { 363 | font-family: 'Roboto'; 364 | font-style: italic; 365 | font-weight: 400; 366 | src: local('Roboto Italic'), local('Roboto-Italic'), url(https://fonts.gstatic.com/s/roboto/v15/cDKhRaXnQTOVbaoxwdOr9xkAz4rYn47Zy2rvigWQf6w.woff2) format('woff2'); 367 | unicode-range: U+0370-03FF; 368 | } 369 | /* vietnamese */ 370 | @font-face { 371 | font-family: 'Roboto'; 372 | font-style: italic; 373 | font-weight: 400; 374 | src: local('Roboto Italic'), local('Roboto-Italic'), url(https://fonts.gstatic.com/s/roboto/v15/K23cxWVTrIFD6DJsEVi07RkAz4rYn47Zy2rvigWQf6w.woff2) format('woff2'); 375 | unicode-range: U+0102-0103, U+1EA0-1EF9, U+20AB; 376 | } 377 | /* latin-ext */ 378 | @font-face { 379 | font-family: 'Roboto'; 380 | font-style: italic; 381 | font-weight: 400; 382 | src: local('Roboto Italic'), local('Roboto-Italic'), url(https://fonts.gstatic.com/s/roboto/v15/vSzulfKSK0LLjjfeaxcREhkAz4rYn47Zy2rvigWQf6w.woff2) format('woff2'); 383 | unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF; 384 | } 385 | /* latin */ 386 | @font-face { 387 | font-family: 'Roboto'; 388 | font-style: italic; 389 | font-weight: 400; 390 | src: local('Roboto Italic'), local('Roboto-Italic'), url(https://fonts.gstatic.com/s/roboto/v15/vPcynSL0qHq_6dX7lKVByXYhjbSpvc47ee6xR_80Hnw.woff2) format('woff2'); 391 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000; 392 | } 393 | /* cyrillic-ext */ 394 | @font-face { 395 | font-family: 'Roboto'; 396 | font-style: italic; 397 | font-weight: 700; 398 | src: local('Roboto Bold Italic'), local('Roboto-BoldItalic'), url(https://fonts.gstatic.com/s/roboto/v15/t6Nd4cfPRhZP44Q5QAjcC_ZraR2Tg8w2lzm7kLNL0-w.woff2) format('woff2'); 399 | unicode-range: U+0460-052F, U+20B4, U+2DE0-2DFF, U+A640-A69F; 400 | } 401 | /* cyrillic */ 402 | @font-face { 403 | font-family: 'Roboto'; 404 | font-style: italic; 405 | font-weight: 700; 406 | src: local('Roboto Bold Italic'), local('Roboto-BoldItalic'), url(https://fonts.gstatic.com/s/roboto/v15/t6Nd4cfPRhZP44Q5QAjcC14sYYdJg5dU2qzJEVSuta0.woff2) format('woff2'); 407 | unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; 408 | } 409 | /* greek-ext */ 410 | @font-face { 411 | font-family: 'Roboto'; 412 | font-style: italic; 413 | font-weight: 700; 414 | src: local('Roboto Bold Italic'), local('Roboto-BoldItalic'), url(https://fonts.gstatic.com/s/roboto/v15/t6Nd4cfPRhZP44Q5QAjcC1BW26QxpSj-_ZKm_xT4hWw.woff2) format('woff2'); 415 | unicode-range: U+1F00-1FFF; 416 | } 417 | /* greek */ 418 | @font-face { 419 | font-family: 'Roboto'; 420 | font-style: italic; 421 | font-weight: 700; 422 | src: local('Roboto Bold Italic'), local('Roboto-BoldItalic'), url(https://fonts.gstatic.com/s/roboto/v15/t6Nd4cfPRhZP44Q5QAjcCwt_Rm691LTebKfY2ZkKSmI.woff2) format('woff2'); 423 | unicode-range: U+0370-03FF; 424 | } 425 | /* vietnamese */ 426 | @font-face { 427 | font-family: 'Roboto'; 428 | font-style: italic; 429 | font-weight: 700; 430 | src: local('Roboto Bold Italic'), local('Roboto-BoldItalic'), url(https://fonts.gstatic.com/s/roboto/v15/t6Nd4cfPRhZP44Q5QAjcC9DiNsR5a-9Oe_Ivpu8XWlY.woff2) format('woff2'); 431 | unicode-range: U+0102-0103, U+1EA0-1EF9, U+20AB; 432 | } 433 | /* latin-ext */ 434 | @font-face { 435 | font-family: 'Roboto'; 436 | font-style: italic; 437 | font-weight: 700; 438 | src: local('Roboto Bold Italic'), local('Roboto-BoldItalic'), url(https://fonts.gstatic.com/s/roboto/v15/t6Nd4cfPRhZP44Q5QAjcC6E8kM4xWR1_1bYURRojRGc.woff2) format('woff2'); 439 | unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF; 440 | } 441 | /* latin */ 442 | @font-face { 443 | font-family: 'Roboto'; 444 | font-style: italic; 445 | font-weight: 700; 446 | src: local('Roboto Bold Italic'), local('Roboto-BoldItalic'), url(https://fonts.gstatic.com/s/roboto/v15/t6Nd4cfPRhZP44Q5QAjcC4gp9Q8gbYrhqGlRav_IXfk.woff2) format('woff2'); 447 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000; 448 | } 449 | 450 | /* fallback */ 451 | @font-face { 452 | font-family: 'Material Icons'; 453 | font-style: normal; 454 | font-weight: 400; 455 | src: local('Material Icons'), local('MaterialIcons-Regular'), url(https://fonts.gstatic.com/s/materialicons/v17/2fcrYFNaTjcS6g4U3t-Y5UEw0lE80llgEseQY3FEmqw.woff2) format('woff2'); 456 | } 457 | 458 | .material-icons { 459 | font-family: 'Material Icons'; 460 | font-weight: normal; 461 | font-style: normal; 462 | font-size: 24px; 463 | line-height: 1; 464 | letter-spacing: normal; 465 | text-transform: none; 466 | display: inline-block; 467 | white-space: nowrap; 468 | word-wrap: normal; 469 | direction: ltr; 470 | -webkit-font-feature-settings: 'liga'; 471 | -webkit-font-smoothing: antialiased; 472 | } 473 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Bobon Profiles 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 |
41 | 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /public/logo_big.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/project-bobon/bobonroastprofile/d70af19cdd0f49cfdc12ebed344a621890176cd5/public/logo_big.png -------------------------------------------------------------------------------- /public/roast_profile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/project-bobon/bobonroastprofile/d70af19cdd0f49cfdc12ebed344a621890176cd5/public/roast_profile.png -------------------------------------------------------------------------------- /scss/app.scss: -------------------------------------------------------------------------------- 1 | // Colors. 2 | $indigo-500: #3F51B5; 3 | $indigo-400: #5C6BC0; 4 | $indigo-900: #1A237E; 5 | 6 | $grey-50: #fafafa; 7 | $grey-100: #f5f5f5; 8 | $grey-200: #eeeeee; 9 | $grey-300: #e0e0e0; 10 | $grey-400: #bdbdbd; 11 | $grey-700: #616161; 12 | $grey-800: #424242; 13 | $grey-900: #212121; 14 | 15 | $red-500: #F44336; 16 | $red-800: #C62828; 17 | $red-900: #B71C1C; 18 | 19 | $teal-500: #009688; 20 | $teal-600: #00897B; 21 | $teal-800: #00695C; 22 | $teal-700: #00796B; 23 | 24 | $deep-orange-900: #BF360C; 25 | 26 | // Util classes. 27 | .bobon-util__full-width { 28 | width: 100%; 29 | } 30 | 31 | .bobon-util__half-width { 32 | width: 50%; 33 | float: left; 34 | } 35 | 36 | .bobon-radio { 37 | margin-right: 25px; 38 | } 39 | 40 | .mdl-card { 41 | background: none; 42 | background: $grey-50; 43 | border: 1px solid $grey-300; 44 | } 45 | 46 | .mdl-card__supporting-text { 47 | color: $grey-900; 48 | } 49 | 50 | .mdl-card__title { 51 | background: #009688; 52 | color: $grey-100; 53 | } 54 | 55 | .mdl-card { 56 | .mdl-card { 57 | margin: 0px; 58 | } 59 | } 60 | 61 | .mdl-card { 62 | min-height: 100px; 63 | } 64 | 65 | .mdl-cell { 66 | min-height: 0; 67 | } 68 | 69 | .material-icons { 70 | margin-right: 10px; 71 | } 72 | 73 | // Layout. 74 | body { 75 | background: $grey-200; 76 | color: $grey-700; 77 | min-height: 100%; 78 | } 79 | 80 | plaintext { 81 | white-space: pre-wrap; 82 | font-family: 'Roboto', 'Helvetica', "Arial"; 83 | } 84 | 85 | .bobon-logo { 86 | color: #fff; 87 | font-weight: 200; 88 | width: 60px; 89 | font-size: 18px; 90 | } 91 | 92 | .bobon-user-avatar { 93 | width: 50px; 94 | height: 50px; 95 | min-width: 50px; 96 | border-radius: 50%; 97 | overflow: hidden; 98 | box-sizing: border-box; 99 | line-height: 50px !important; 100 | padding: 0px !important; 101 | text-align: center; 102 | position: relative; 103 | margin-left: 10px; 104 | 105 | &:focus { 106 | outline: none; 107 | } 108 | 109 | img { 110 | position: absolute; 111 | top: 0px; 112 | left: 0px; 113 | width: 100%; 114 | } 115 | 116 | .material-icons { 117 | font-size: 50px; 118 | margin: 0px; 119 | } 120 | } 121 | 122 | .bobon-menu-user-menu { 123 | background: $teal-600; 124 | 125 | li { 126 | color: $grey-100; 127 | 128 | &:hover { 129 | background: $teal-500; 130 | } 131 | } 132 | } 133 | 134 | .bobon-unit-switcher-menu { 135 | button { 136 | color: $grey-100; 137 | background: $teal-600; 138 | border-radius: 18px; 139 | 140 | &:hover { 141 | background: $teal-800; 142 | } 143 | } 144 | 145 | .mdl-menu { 146 | background: $teal-600; 147 | 148 | .mdl-menu__item { 149 | color: $grey-100; 150 | &:hover { 151 | background: $teal-500; 152 | } 153 | } 154 | } 155 | 156 | } 157 | 158 | .bobon-page-content { 159 | padding: 0px; 160 | box-sizing: border-box; 161 | } 162 | 163 | .bobon-textfield-wrapper { 164 | padding: 0 10px; 165 | box-sizing: border-box; 166 | float: left; 167 | 168 | > .mdl-textfield { 169 | width: 100%; 170 | } 171 | } 172 | 173 | .bobon-main-content { 174 | width: 100%; 175 | margin: 0 auto; 176 | } 177 | 178 | .bobon-text-with-icon { 179 | position: relative; 180 | padding-left: 30px; 181 | 182 | .material-icons { 183 | position: absolute; 184 | top: 50%; 185 | margin-top: -12px; 186 | left: 0px; 187 | } 188 | } 189 | 190 | .bobon-roast-temp-form { 191 | width: 100%; 192 | position: relative; 193 | box-sizing: content-box; 194 | padding-right: 240px; 195 | 196 | input[type="submit"] { 197 | position: absolute; 198 | top: 0px; 199 | right: 0px; 200 | height: 50px; 201 | line-height: 50px; 202 | width: 200px; 203 | text-align: center; 204 | box-sizing: border-box; 205 | margin: 0; 206 | } 207 | } 208 | 209 | .bobon-roast-temp-input { 210 | width: 100%; 211 | border: none; 212 | box-shadow: none; 213 | border-bottom: 1px solid #e3e3e3; 214 | font-size: 3em; 215 | padding: 0; 216 | display: block; 217 | height: 50px; 218 | line-height: 50px; 219 | &:focus { 220 | outline-width: 0; 221 | } 222 | } 223 | 224 | @keyframes hot { 225 | 0% { 226 | color: #B71C1C; 227 | } 228 | 25% { 229 | color: #E64A19; 230 | } 231 | 50% { 232 | color: #FFEE58; 233 | } 234 | 100% { 235 | color: #E64A19; 236 | } 237 | } 238 | 239 | .bobon-roast-status--roast_pending { 240 | color: #FF6F00; 241 | } 242 | 243 | 244 | .bobon-roast-status--roast_in_progress { 245 | color: #B71C1C; 246 | min-width: 1px; 247 | width: 38px; 248 | padding: 0px; 249 | text-align: center; 250 | 251 | .material-icons { 252 | margin: 0px; 253 | animation: hot 1s ease-out infinite; 254 | } 255 | } 256 | 257 | .bobon-roast-status--roast_completed { 258 | color: #43A047; 259 | } 260 | 261 | header { 262 | .mdl-button { 263 | &.mdl-button--colored { 264 | &.bobon-roast-status--roast_in_progress { 265 | border-radius: 18px; 266 | color: #fff; 267 | background: $teal-600; 268 | margin-right: 15px; 269 | 270 | &:hover { 271 | background: $teal-800; 272 | } 273 | } 274 | } 275 | } 276 | } 277 | 278 | .bobon-section { 279 | width: 100%; 280 | clear: both; 281 | 282 | &.bobon-section-colored { 283 | background: rgb(33, 150, 243); 284 | color: #ebebeb; 285 | } 286 | } 287 | 288 | .bobon-chart-title { 289 | font-size: 1.2em; 290 | padding: 7px 0; 291 | color: $indigo-900; 292 | text-transform: uppercase; 293 | } 294 | 295 | .bobon-stopwatch { 296 | height: 50px; 297 | position: relative; 298 | padding-left: 130px; 299 | } 300 | 301 | .bobon-stopwatch-time { 302 | font-size: 3em; 303 | line-height: 50px; 304 | height: 50px; 305 | } 306 | 307 | .bobon-stopwatch-button { 308 | position: absolute; 309 | left: 0px; 310 | top: 0px; 311 | height: 100%; 312 | line-height: 50px; 313 | text-align: center; 314 | background: $deep-orange-900 !important; 315 | color: $grey-100 !important; 316 | } 317 | 318 | .bobon-header-button { 319 | position: fixed; 320 | top: 35px; 321 | z-index: 99; 322 | } 323 | 324 | .bobon-header-button-add-new { 325 | left: 16px; 326 | } 327 | 328 | .mdl-layout__content { 329 | padding-top: 20px; 330 | } 331 | 332 | .bobon-dialog { 333 | width: 400px; 334 | border: 0; 335 | z-index: 9999; 336 | overflow: visible; 337 | display: inline-block; 338 | } 339 | 340 | .bobon-dialog-container { 341 | display: table; 342 | position: fixed; 343 | width: 100%; 344 | height: 100%; 345 | top: 0px; 346 | left: 0px; 347 | z-index: 9999; 348 | 349 | &:before { 350 | content: ""; 351 | display: block; 352 | background: rgba(0,0,0,0.7); 353 | position: fixed; 354 | top: 0px; 355 | left: 0px; 356 | width: 100%; 357 | height: 100%; 358 | z-index: 9; 359 | } 360 | } 361 | 362 | .bobon-dialog-cell { 363 | display: table-cell; 364 | vertical-align: middle; 365 | text-align: center; 366 | 367 | .mdl-card { 368 | text-align: left; 369 | } 370 | 371 | .mdl-card__title { 372 | font-size: 1.5em; 373 | font-weight: 200; 374 | padding-top: 30px; 375 | padding-bottom: 30px; 376 | } 377 | } 378 | 379 | .bobon-spinner { 380 | width: 50px; 381 | height: 50px; 382 | position: fixed; 383 | top: 50%; 384 | left: 50%; 385 | margin-top: -25px; 386 | margin-left: -25px; 387 | } 388 | 389 | .bobon-transition-enter { 390 | transform: translateY(30px); 391 | opacity: 0.01; 392 | transition: opacity 0.3s ease, transform 0.3s ease; 393 | 394 | &.bobon-transition-enter-active { 395 | transform: translateY(0); 396 | opacity: 1; 397 | } 398 | } 399 | 400 | .bobon-transition-leave { 401 | display: none; 402 | 403 | &.bobon-transition-leave-active { 404 | opacity: 0; 405 | } 406 | 407 | } 408 | 409 | .bobon-transition-appear { 410 | transform: translateY(30px); 411 | opacity: 0.01; 412 | 413 | &.bobon-transition-appear-active { 414 | transform: translateY(0); 415 | opacity: 1; 416 | transition: opacity 0.3s ease, transform 0.3s ease; 417 | } 418 | } 419 | 420 | .bobon-header-appear { 421 | transform: translateY(-30px); 422 | opacity: 0.01; 423 | 424 | &.bobon-header-appear-active { 425 | transform: translateY(0); 426 | opacity: 1; 427 | transition: opacity 0.3s ease, transform 0.3s ease; 428 | } 429 | } 430 | 431 | 432 | .bobon-table-cell--beans-name { 433 | white-space: normal; 434 | min-width: 150px; 435 | } 436 | 437 | .bobon-roast-profile-sidebar { 438 | padding-left: 0px; 439 | padding-right: 0px; 440 | 441 | > .mdl-cell { 442 | width: 100%; 443 | margin-left: 0px; 444 | margin-right: 0px; 445 | } 446 | } 447 | 448 | .mdl-grid { 449 | align-items: flex-start; 450 | } 451 | 452 | .bobon-login-form { 453 | position: fixed; 454 | width: 300px; 455 | left: 50%; 456 | margin-left: -150px; 457 | top: 50%; 458 | margin-top: -145px; 459 | border: 0; 460 | } 461 | 462 | .bobon-anon { 463 | position: fixed; 464 | top: 0px; 465 | left: 0px; 466 | width: 100%; 467 | height: 100%; 468 | background: #009688; 469 | } 470 | 471 | 472 | .bobon-instructions { 473 | text-align: center; 474 | padding: 0px; 475 | 476 | .mdl-cell { 477 | position: relative; 478 | padding-top: 180px; 479 | padding-bottom: 20px; 480 | 481 | img { 482 | max-width: 100%; 483 | } 484 | 485 | .mdl-typography--font-thin { 486 | position: absolute; 487 | top: 20px; 488 | left: 0px; 489 | width: 100%; 490 | box-sizing: border-box; 491 | padding: 0 25px; 492 | margin-bottom: 40px; 493 | color: #fff; 494 | } 495 | } 496 | } 497 | 498 | .bobon-home-anonymous { 499 | position: fixed; 500 | width: 100%; 501 | height: 100%; 502 | top: 0px; 503 | left: 0px; 504 | 505 | canvas { 506 | position: fixed; 507 | bottom: 0px; 508 | left: 50%; 509 | margin-left: -1200px; 510 | } 511 | 512 | .bobon-instructions { 513 | position: fixed; 514 | width: 100%; 515 | bottom: 80px; 516 | color: #fff; 517 | } 518 | } 519 | 520 | .bobon-footer { 521 | padding: 10px 20px; 522 | background: $teal-500; 523 | color: #fff; 524 | font-weight: 200; 525 | font-size: 0.9em; 526 | text-align: right; 527 | bottom: 0px; 528 | box-sizing: border-box; 529 | min-height: 40px; 530 | 531 | a { 532 | font-weight: 200; 533 | color: #fff; 534 | } 535 | } 536 | 537 | .bobon-form__new-roast { 538 | width: 100%; 539 | margin: 0; 540 | padding: 10px; 541 | box-sizing: border-box; 542 | float: left; 543 | } 544 | 545 | .bobon-list__compare-select { 546 | max-height: 200px; 547 | overflow-y: auto; 548 | } 549 | 550 | @media screen and (max-width: 1024px) { 551 | .mdl-layout__header { 552 | display: flex; 553 | } 554 | 555 | .mdl-layout { 556 | > span { 557 | z-index: 9999; 558 | } 559 | } 560 | 561 | .mdl-button-nav-start-roast { 562 | display: none; 563 | } 564 | 565 | .bobon-button-text--nav-action { 566 | display: none; 567 | } 568 | 569 | .bobon-util__half-width { 570 | width: 100% !important; 571 | } 572 | 573 | .bobon-header-button { 574 | top: 25px; 575 | } 576 | 577 | header { 578 | .mdl-navigation { 579 | .mdl-button { 580 | padding: 0 10px; 581 | min-width: 10px; 582 | 583 | .material-icons { 584 | margin-right: 0px; 585 | } 586 | } 587 | } 588 | } 589 | } 590 | 591 | @media screen and (max-width: 839px) { 592 | .mdl-grid { 593 | padding: 8px 0; 594 | } 595 | 596 | .bobon-table-cell--hidden-mobile { 597 | display: none; 598 | } 599 | 600 | .bobon-instructions { 601 | .mdl-cell { 602 | padding-top: 10px; 603 | 604 | .mdl-typography--font-thin { 605 | position: relative; 606 | } 607 | } 608 | } 609 | } 610 | -------------------------------------------------------------------------------- /src/actions.js: -------------------------------------------------------------------------------- 1 | import firebase from 'firebase'; 2 | 3 | import C from './constants'; 4 | import history from './history'; 5 | 6 | // Auth actions. 7 | export const listeningToAuth = () => { 8 | return { 9 | type: C.LISTENING_TO_AUTH 10 | }; 11 | }; 12 | 13 | export const loginRequest = (method = 'google', nextPath = '/') => { 14 | return { 15 | type: C.LOGIN_REQUEST, 16 | method, 17 | nextPath 18 | }; 19 | }; 20 | 21 | export const loginSuccess = (user, nextPath = '/') => { 22 | history.push(nextPath); 23 | return { 24 | type: C.LOGIN_SUCCESS, 25 | user, 26 | nextPath 27 | }; 28 | }; 29 | 30 | export const logout = (nextPath = '/') => { 31 | history.push(nextPath); 32 | return { 33 | type: C.LOGOUT, 34 | nextPath 35 | }; 36 | }; 37 | 38 | export const startListeningToAuth = () => { 39 | return function (dispatch) { 40 | dispatch(listeningToAuth()); 41 | // Start listening to firebase auth changes. 42 | C.FIREBASE.auth().onAuthStateChanged(authData => { 43 | // If logged in. 44 | if (authData) { 45 | dispatch(loginSuccess(authData)); 46 | } else { 47 | dispatch(logout()); 48 | } 49 | }); 50 | }; 51 | }; 52 | 53 | // Roasts actions. 54 | export const startListeningToRoasts = () => { 55 | if (C.FIREBASE.auth().currentUser) { 56 | const uid = C.FIREBASE.auth().currentUser.uid; 57 | return (dispatch, getState) => { 58 | let roastsRef = C.FIREBASE.app().database().ref('roasts').equalTo('uid', uid); 59 | roastsRef.on('value', snapshot => { 60 | console.log(snapshot.val()); 61 | dispatch(fetchedRoasts(snapshot.val())); 62 | }, err => { 63 | console.log(err); 64 | }); 65 | }; 66 | } else { 67 | return (dispatch, getState) => { 68 | dispatch(logout()); 69 | }; 70 | } 71 | }; 72 | 73 | export const fetchedRoasts = (roasts) => { 74 | return { 75 | type: C.FETCHED_ROASTS, 76 | roasts 77 | }; 78 | }; 79 | 80 | export const compareRoasts = (roastId, compareId) => { 81 | return { 82 | type: C.COMPARE_ROASTS, 83 | roastId, 84 | compareId 85 | }; 86 | }; 87 | 88 | export const addFirstCrack = (roastId, firstCrackTime) => { 89 | return { 90 | type: C.ADD_FIRST_CRACK, 91 | roastId, 92 | firstCrackTime 93 | }; 94 | }; 95 | 96 | // New roast actions. 97 | export const createNewRoast = (roastDetails) => { 98 | return { 99 | type: C.CREATING_NEW_ROAST, 100 | roastDetails: roastDetails 101 | }; 102 | }; 103 | 104 | export const removeRoast = roastId => { 105 | return { 106 | type: C.REMOVE_ROAST, 107 | roastId 108 | }; 109 | }; 110 | 111 | export const createNewRoastSuccess = (roastData) => { 112 | return { 113 | type: C.CREATE_NEW_ROAST_SUCCESS, 114 | roastData: roastData 115 | }; 116 | }; 117 | 118 | export const createNewRoastFailed = (error) => { 119 | return { 120 | type: C.CREATE_NEW_ROAST_FAILED, 121 | error 122 | }; 123 | }; 124 | 125 | export const updateRoastValue = (roastId, field, value) => { 126 | return { 127 | type: C.UPDATE_ROAST_VALUE, 128 | roastId, 129 | field, 130 | value 131 | }; 132 | }; 133 | 134 | export const updateCurrentRoastValue = (field, value) => { 135 | return { 136 | type: C.UPDATE_CURRENT_ROAST_VALUE, 137 | field, 138 | value 139 | }; 140 | }; 141 | 142 | // Field actions. 143 | export const toggleEditing = (roastId, field) => { 144 | return { 145 | type: C.TOGGLE_EDITING_FIELD, 146 | roastId, 147 | field 148 | }; 149 | }; 150 | 151 | // Stopwatch actions. 152 | export const startStopWatch = (roastId, roastStart, tick) => { 153 | let uid = C.FIREBASE.auth().currentUser.uid; 154 | let roastRef = C.FIREBASE.app().database().ref(`roasts/${uid}/${roastId}`); 155 | 156 | roastRef.update({ 157 | status: C.ROAST_IN_PROGRESS, 158 | roastStart 159 | }); 160 | 161 | return { 162 | type: C.STOPWATCH_START, 163 | roastId, 164 | tick 165 | }; 166 | }; 167 | 168 | export const resumeStopWatch = (roastId, roastStart, tick) => { 169 | return { 170 | type: C.STOPWATCH_RESUME, 171 | roastId, 172 | tick 173 | }; 174 | }; 175 | 176 | export const tickStopWatch = (roastStart) => { 177 | return { 178 | type: C.STOPWATCH_TICK, 179 | roastStart 180 | }; 181 | }; 182 | 183 | export const stopStopWatch = (roastId) => { 184 | let uid = C.FIREBASE.auth().currentUser.uid; 185 | let roastRef = C.FIREBASE.app().database().ref(`roasts/${uid}/${roastId}`); 186 | 187 | let promise = roastRef.update({ 188 | status: C.ROAST_COMPLETED 189 | }); 190 | 191 | return { 192 | type: C.STOPWATCH_STOP, 193 | roastId, 194 | promise 195 | }; 196 | }; 197 | 198 | // Roast progress actions. 199 | export const checkRoastInProgress = roasts => { 200 | return { 201 | type: C.CHECK_ROAST_IN_PROGRESS, 202 | roasts 203 | }; 204 | }; 205 | 206 | // Dialog actions. 207 | export const showDialog = ({ 208 | dialogType='info', 209 | noAction = null, 210 | noText = 'No', 211 | text, 212 | yesAction, 213 | yesText = 'Yes' 214 | }) => { 215 | return { 216 | dialogType, 217 | noAction, 218 | noText, 219 | text, 220 | type: C.SHOW_DIALOG, 221 | yesAction, 222 | yesText 223 | }; 224 | }; 225 | 226 | export const clearDialog = () => { 227 | return { 228 | type: C.CLEAR_DIALOG 229 | }; 230 | }; 231 | 232 | // Data loading. 233 | export const loadingData = () => { 234 | return { 235 | type: C.LOADING_DATA 236 | }; 237 | } 238 | 239 | export const loadedData = () => { 240 | return { 241 | type: C.LOADED_DATA 242 | }; 243 | } 244 | 245 | // Settings. 246 | export const updateSetting = (setting, value) => { 247 | return { 248 | type: C.UPDATE_SETTING, 249 | setting, 250 | value 251 | }; 252 | } 253 | 254 | export const fetchedSettings = settings => { 255 | return { 256 | type: C.FETCHED_SETTINGS, 257 | settings 258 | }; 259 | } 260 | -------------------------------------------------------------------------------- /src/app.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactGA from 'react-ga'; 3 | import thunkMiddleware from 'redux-thunk'; 4 | import { Provider } from 'react-redux'; 5 | import { Router, Route, IndexRoute, Link, Redirect } from 'react-router'; 6 | import { createStore, applyMiddleware, compose } from 'redux'; 7 | import { render } from 'react-dom'; 8 | 9 | import AppContainer from './containers/AppContainer'; 10 | import C from './constants'; 11 | import Home from './components/Home'; 12 | import MainContainer from './containers/MainContainer'; 13 | import NewRoastFormContainer from './containers/NewRoastFormContainer'; 14 | import RoastProfileContainer from './containers/RoastProfileContainer'; 15 | import SettingsContainer from './containers/SettingsContainer'; 16 | import auth from './auth'; 17 | import history from './history'; 18 | import rootReducer from './reducers/index'; 19 | import { 20 | checkRoastInProgress, 21 | fetchedRoasts, 22 | fetchedSettings, 23 | listeningToAuth, 24 | loadedData, 25 | loadingData, 26 | loginSuccess, 27 | logout 28 | } from './actions'; 29 | 30 | const store = applyMiddleware(thunkMiddleware)(createStore)(rootReducer, {} 31 | ,window.devToolsExtension && window.devToolsExtension() 32 | ); 33 | 34 | // Analytics 35 | ReactGA.initialize(C.GOOGLE_ANALYTICS_CODE); 36 | 37 | const logPageView = () => { 38 | if (window.location.hostname !== 'localhost') { 39 | ReactGA.pageview(window.location.hash); 40 | } 41 | }; 42 | 43 | const routes = ( 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | ); 57 | 58 | render( 59 | 60 | { routes } 61 | , 62 | document.getElementById("root") 63 | ); 64 | 65 | store.dispatch(listeningToAuth()); 66 | 67 | // Start listening to firebase auth changes. 68 | C.FIREBASE.auth().onAuthStateChanged((user) => { 69 | // If logged in. 70 | if (user) { 71 | store.dispatch(loginSuccess(user)); 72 | 73 | // Listen to roast changes 74 | let roastsRef = C.FIREBASE.app().database().ref(`/roasts/${user.uid}`); 75 | 76 | store.dispatch(loadingData()); 77 | roastsRef.on('value', snapshot => { 78 | store.dispatch(fetchedRoasts(snapshot.val())); 79 | store.dispatch(loadedData()); 80 | store.dispatch(checkRoastInProgress(snapshot.val())); 81 | }, err => { 82 | console.log(err); 83 | }); 84 | 85 | // Listen to settings changes. 86 | let settingsRef = C.FIREBASE.app().database().ref(`/settings/${user.uid}`); 87 | 88 | store.dispatch(loadingData()); 89 | settingsRef.on('value', snapshot => { 90 | store.dispatch(loadedData()); 91 | store.dispatch(fetchedSettings(snapshot.val())); 92 | }); 93 | 94 | } else { 95 | C.FIREBASE.auth().getRedirectResult().then(function(result) { 96 | if (!result.user) { 97 | store.dispatch(logout()); 98 | } else { 99 | store.dispatch(loginSuccess(result.user)); 100 | } 101 | }); 102 | } 103 | }, err => { 104 | console.log(err); 105 | }); 106 | -------------------------------------------------------------------------------- /src/auth.js: -------------------------------------------------------------------------------- 1 | import { browserHistory } from 'react-router'; 2 | import C from './constants'; 3 | 4 | const auth = { 5 | login: (provider) => { 6 | let authProvider = null; 7 | 8 | switch (provider) { 9 | case 'facebook': 10 | authProvider = new firebase.auth.FacebookAuthProvider(); 11 | break; 12 | 13 | case 'google': 14 | authProvider = new firebase.auth.GoogleAuthProvider(); 15 | break; 16 | 17 | default: 18 | break; 19 | } 20 | 21 | if (window.location.protocol === 'http') { 22 | return C.FIREBASE.auth().signInWithPopup(authProvider); 23 | } else { 24 | return C.FIREBASE.auth().signInWithRedirect(authProvider); 25 | } 26 | }, 27 | 28 | isLoggedIn: () => { 29 | return C.FIREBASE.auth().currentUser; 30 | }, 31 | 32 | checkAuth: (nextState, replace, cb) => { 33 | if (!C.FIREBASE.auth().currentUser) { 34 | replace({ 35 | pathname: '/', 36 | state: { nextPathname: nextState.location.pathname } 37 | }); 38 | } 39 | cb(); 40 | } 41 | }; 42 | 43 | export default auth; 44 | -------------------------------------------------------------------------------- /src/components/App.jsx: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import ReactCSSTransitionGroup from 'react-addons-css-transition-group'; 3 | import { Link, browserHistory } from 'react-router'; 4 | 5 | import C from '../constants'; 6 | import DialogContainer from '../containers/DialogContainer'; 7 | import HeaderContainer from '../containers/HeaderContainer'; 8 | import Spinner from './Spinner'; 9 | 10 | require('../../scss/app.scss'); 11 | 12 | class App extends React.Component { 13 | 14 | static propTypes() { 15 | return { 16 | uid: PropTypes.string, 17 | authStatus: PropTypes.string.isRequired, 18 | userName: PropTypes.string 19 | }; 20 | } 21 | 22 | componentDidUpdate() { 23 | componentHandler.upgradeDom(); 24 | } 25 | 26 | pageContent() { 27 | let content = ; 28 | if (!this.props.dataLoading) { 29 | content = ( 30 | 37 | { React.cloneElement(this.props.children, { 38 | key: this.props.location.pathname 39 | }) } 40 | 41 | ); 42 | } 43 | return content; 44 | } 45 | 46 | render() { 47 | let extraClass = ''; 48 | let content = null; 49 | 50 | if (this.props.authStatus !== C.LOGGED_IN) { 51 | extraClass = "bobon-anon"; 52 | } 53 | 54 | if (this.props.authStatus === C.LOGGING_IN) { 55 | content = ; 56 | } else { 57 | content = ( 58 |
59 | 60 | 61 |
62 |
63 | { this.pageContent() } 64 |
65 | 66 |
67 | 68 | 71 | 72 | 73 |
74 | ); 75 | } 76 | 77 | return content; 78 | } 79 | }; 80 | 81 | export default App; 82 | -------------------------------------------------------------------------------- /src/components/Dialog.jsx: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | 3 | import Button from './utils/Button'; 4 | import Card from './utils/Card'; 5 | import CardTitle from './utils/CardTitle'; 6 | import CardAction from './utils/CardAction'; 7 | 8 | class Dialog extends React.Component { 9 | static propTypes() { 10 | return { 11 | clearDialog: PropTypes.func.isRequired, 12 | dialogType: PropTypes.string, 13 | noAction: PropTypes.func, 14 | noText: PropTypes.string, 15 | text: PropTypes.string.isRequired, 16 | yesAction: PropTypes.func, 17 | yesText: PropTypes.string 18 | } 19 | } 20 | 21 | render() { 22 | let content = null; 23 | if (this.props.text) { 24 | content = ( 25 |
26 |
27 | 28 | 29 | { this.props.text } 30 | 31 | 32 | 43 | 44 | 55 | 56 | 57 |
58 |
59 | ); 60 | } 61 | 62 | return content; 63 | } 64 | } 65 | 66 | export default Dialog; 67 | -------------------------------------------------------------------------------- /src/components/Header.jsx: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import ReactCSSTransitionGroup from 'react-addons-css-transition-group'; 3 | 4 | import Button from './utils/Button'; 5 | import C from '../constants'; 6 | import history from '../history'; 7 | import UnitSwitcherContainer from '../containers/UnitSwitcherContainer'; 8 | 9 | class Header extends React.Component { 10 | 11 | static propTypes() { 12 | return { 13 | authStatus: PropTypes.string.isRequired, 14 | photoURL: PropTypes.string, 15 | userName: PropTypes.string 16 | }; 17 | } 18 | 19 | userMenu() { 20 | let content = null; 21 | 22 | if (this.props.authStatus === C.LOGGED_IN) { 23 | content = ( 24 | 68 | ); 69 | } 70 | 71 | return content; 72 | } 73 | 74 | profilePhoto() { 75 | let photo = null; 76 | 77 | if (this.props.authStatus === C.LOGGED_IN) { 78 | if (this.props.photoURL && this.props.photoURL !== '') { 79 | photo = ( 80 | 89 | ); 90 | } else { 91 | photo = ( 92 | 99 | ); 100 | } 101 | } else { 102 | photo = ( 103 | { 105 | e.preventDefault(); 106 | history.push('/') 107 | } } 108 | > 109 | Bobon Profiles 110 | 111 | ); 112 | } 113 | 114 | return photo; 115 | } 116 | 117 | actionButton() { 118 | let actionButton = null; 119 | 120 | if (this.props.authStatus === C.LOGGED_IN) { 121 | actionButton = ( 122 |
123 | 124 | { this.newRoastBtnNav() } 125 | 126 | 136 | 137 |
140 | My roasts 141 |
142 |
143 | ); 144 | } 145 | 146 | return actionButton; 147 | } 148 | 149 | roastInProgress() { 150 | let content = null; 151 | 152 | if (this.props.roastInProgress) { 153 | content = ( 154 | 162 | ); 163 | } 164 | 165 | return content; 166 | } 167 | 168 | newRoastBtnNav() { 169 | let content = null; 170 | let location = this.props.location; 171 | 172 | if ( 173 | this.props.authStatus === C.LOGGED_IN && 174 | this.props.roastInProgress === null 175 | ) { 176 | content = ( 177 | 187 | ); 188 | } 189 | 190 | return content; 191 | } 192 | 193 | newRoastBtn() { 194 | let content = null; 195 | let location = this.props.location; 196 | 197 | if ( 198 | this.props.authStatus === C.LOGGED_IN && 199 | this.props.roastInProgress === null 200 | ) { 201 | content = ( 202 |
203 | 212 |
213 | Start a new roast! 214 |
215 |
216 | ); 217 | } 218 | 219 | return content; 220 | } 221 | 222 | unitSwitcher() { 223 | let content = null; 224 | if (this.props.authStatus === C.LOGGED_IN) { 225 | content = ; 226 | } 227 | 228 | return content; 229 | } 230 | 231 | render() { 232 | return ( 233 | 239 |
240 |
241 | 242 | 243 |
244 | 250 |
251 | { this.newRoastBtn() } 252 |
253 | { this.userMenu() } 254 |
255 | ); 256 | } 257 | 258 | } 259 | 260 | export default Header; 261 | -------------------------------------------------------------------------------- /src/components/Home.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import LoginFormContainer from '../containers/LoginFormContainer'; 3 | 4 | class Home extends React.Component { 5 | render() { 6 | return ; 7 | } 8 | } 9 | 10 | export default Home; 11 | -------------------------------------------------------------------------------- /src/components/Instructions.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const imgCreate = require('../../images/step_create.png'); 4 | const imgInput = require('../../images/step_input.png'); 5 | const imgStart = require('../../images/step_timer.png'); 6 | const imgStop = require('../../images/step_stop.png'); 7 | 8 | class Instructions extends React.Component { 9 | render() { 10 | return ( 11 |
12 |
15 |
16 | 1. Create roast 17 |
18 | 19 |
20 | 21 |
24 |
25 | 2. Drop beans & start timer 26 |
27 | 28 |
29 | 30 |
33 |
34 | 3. Enter temperatures. Push FIRST CRACK when it happens 35 |
36 | 37 |
38 | 39 |
42 |
43 | 4. Stop timer & complete the roast 44 |
45 | 46 |
47 |
48 | ); 49 | } 50 | } 51 | 52 | export default Instructions; 53 | -------------------------------------------------------------------------------- /src/components/LoginForm.jsx: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import { browserHistory } from 'react-router'; 3 | 4 | import Button from './utils/Button'; 5 | import C from '../constants'; 6 | import Card from './utils/Card'; 7 | import CardAction from './utils/CardAction'; 8 | import CardContent from './utils/CardContent'; 9 | import CardTitle from './utils/CardTitle'; 10 | 11 | const LoginForm = ({ authStatus, onLoginBtnClick }) => { 12 | let content = null; 13 | 14 | if (authStatus !== C.LOGGED_IN) { 15 | content = ( 16 | 17 | 18 |
19 | Bobon Profiles 20 |
21 |
22 | 23 |
24 | A real-time profile platform for home roasting coffee 25 |
26 |
27 | 28 | 31 | 34 | 35 |
36 | ); 37 | } 38 | 39 | return content; 40 | }; 41 | 42 | LoginForm.propTypes = { 43 | authStatus: PropTypes.string.isRequired, 44 | onLoginBtnClick: PropTypes.func.isRequired 45 | }; 46 | 47 | export default LoginForm; 48 | -------------------------------------------------------------------------------- /src/components/Main.jsx: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import { Line } from 'react-chartjs'; 3 | 4 | import C from '../constants'; 5 | import Home from './Home'; 6 | import RoastListContainer from '../containers/RoastListContainer'; 7 | import { demoDataset, demoChartOptions } from '../demoData'; 8 | 9 | 10 | class Main extends React.Component { 11 | static propTypes() { 12 | return { 13 | authStatus: PropTypes.string.isRequired 14 | }; 15 | } 16 | 17 | render() { 18 | let content = null; 19 | if (this.props.authStatus === C.LOGGED_IN) { 20 | content = (); 21 | } else { 22 | content = ( 23 |
24 | 30 | 31 |
32 | ); 33 | } 34 | 35 | return content; 36 | } 37 | } 38 | 39 | export default Main; 40 | -------------------------------------------------------------------------------- /src/components/NewRoastForm.jsx: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import history from '../history'; 3 | 4 | import Button from './utils/Button'; 5 | import Card from './utils/Card'; 6 | import CardContent from './utils/CardContent'; 7 | import CardTitle from './utils/CardTitle'; 8 | import Spinner from './Spinner'; 9 | import UnitSwitcherContainer from '../containers/UnitSwitcherContainer'; 10 | 11 | class NewRoastForm extends React.Component { 12 | cancelButton() { 13 | return( 14 | 26 | ); 27 | } 28 | 29 | submitButton() { 30 | if (this.props.disabled === true) { 31 | return( 32 | 42 | ); 43 | } else { 44 | return( 45 | 54 | ); 55 | } 56 | } 57 | 58 | render() { 59 | if (this.props.processing) { 60 | return ; 61 | } else { 62 | return ( 63 |
64 | 65 | 66 | 67 |
68 | timer 69 | Create new roast 70 |
71 |
72 | 73 | 74 | 75 | 76 | 77 |
81 | 82 |
83 |
86 | 93 | 94 | 100 | 101 |
102 |
103 | 104 |
105 |
108 | 116 | 117 | 123 | 124 |
125 |
126 | 127 |
128 |
131 | 139 | 140 | 146 |
147 |
148 | 149 |
150 |
153 | 161 | 162 | 168 | 169 |
170 |
171 | 172 |
173 |
176 | 184 | 185 | 191 |
192 |
193 | 194 | 200 | 201 | 207 | 208 |
209 | { this.submitButton() } 210 | { this.cancelButton() } 211 |
212 | 213 |
214 |
215 |
216 | ); 217 | } 218 | } 219 | } 220 | 221 | export default NewRoastForm; 222 | -------------------------------------------------------------------------------- /src/components/PostRoastNoteForm.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import C from '../constants'; 4 | import Card from './utils/Card'; 5 | import CardAction from './utils/CardAction'; 6 | import CardContent from './utils/CardContent'; 7 | import CardTitle from './utils/CardTitle'; 8 | 9 | class PostRoastNoteForm extends React.Component { 10 | 11 | componentDidUpdate(props) { 12 | componentHandler.upgradeDom(); 13 | } 14 | 15 | noteInput() { 16 | let content = null; 17 | let autoFocus = false; 18 | 19 | if (this.props.editing) { 20 | autoFocus = true; 21 | } 22 | content = ( 23 | 33 | ); 34 | 35 | return content; 36 | } 37 | 38 | actionButton() { 39 | let content = null; 40 | 41 | if (this.props.editing === C.FIELD_STATUS_NOT_EDITING) { 42 | let btnText = 'Edit'; 43 | 44 | if (this.props.postRoastNote === '' || !this.props.postRoastNote) { 45 | btnText = 'Add Comment'; 46 | } 47 | 48 | content = ( 49 | 50 | 62 | 63 | ); 64 | } 65 | 66 | return content; 67 | } 68 | 69 | noteForm() { 70 | if ((this.props.postRoastNote === '' || this.props.postRoastNote === null) && 71 | this.props.editing !== C.FIELD_STATUS_EDITING 72 | ) { 73 | return null; 74 | } 75 | 76 | let content = ( 77 | 78 | 79 | { this.props.postRoastNote } 80 | </plaintext> 81 | </CardContent> 82 | ); 83 | 84 | if (this.props.editing === C.FIELD_STATUS_EDITING) { 85 | content = ( 86 | <CardAction> 87 | <form onSubmit={ this.props.onSubmit }> 88 | 89 | <input 90 | type="hidden" 91 | defaultValue={ this.props.roastId } 92 | name="roastId" 93 | id="roastId" 94 | /> 95 | 96 | <div className="bobon-textfield-wrapper bobon-util__full-width"> 97 | <div className="mdl-textfield mdl-js-textfield"> 98 | { this.noteInput() } 99 | </div> 100 | </div> 101 | 102 | <input 103 | className="mdl-button mdl-js-button mdl-js-ripple-effect" 104 | type="submit" 105 | value="Save Comment" 106 | /> 107 | 108 | <button className="mdl-button mdl-js-button mdl-js-ripple-effect" 109 | onClick={ (e) => { 110 | e.preventDefault(); 111 | this.props.toggleEditing( 112 | this.props.roastId, 113 | C.FIELD_POST_ROAST_NOTE 114 | ); 115 | } } 116 | > 117 | Cancel 118 | </button> 119 | 120 | </form> 121 | </CardAction> 122 | ); 123 | } 124 | 125 | return content; 126 | } 127 | 128 | render() { 129 | let content = null; 130 | 131 | if (this.props.status === C.ROAST_COMPLETED) { 132 | content = ( 133 | <Card customClass="mdl-cell mdl-cell--6-col mdl-cell--12-col-tablet"> 134 | <CardTitle> 135 | <div className="bobon-text-with-icon"> 136 | <i className="material-icons">receipt</i> 137 | Post-roasting notes 138 | </div> 139 | </CardTitle> 140 | 141 | { this.noteForm() } 142 | 143 | { this.actionButton() } 144 | </Card> 145 | ); 146 | } 147 | 148 | return content; 149 | } 150 | } 151 | 152 | export default PostRoastNoteForm; 153 | -------------------------------------------------------------------------------- /src/components/RoastChart.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Line } from 'react-chartjs'; 3 | 4 | import C from '../constants'; 5 | import { displayTemp } from '../helpers'; 6 | 7 | class RoastChart extends React.Component { 8 | 9 | constructor(props) { 10 | super(props); 11 | this.state = { 12 | redraw: false, 13 | compare: null 14 | }; 15 | } 16 | 17 | componentWillReceiveProps(nextProps) { 18 | // Persistent comparing state. 19 | if (this.props.compare) { 20 | this.setState({ 21 | compare: this.props.compare, 22 | redraw: false 23 | }); 24 | } 25 | 26 | if (this.props && this.props.roastPoints && nextProps.roastPoints) { 27 | if (Object.keys(nextProps.roastPoints).length < Object.keys(this.props.roastPoints).length) { 28 | this.setState({ redraw: true }); 29 | } else { 30 | this.setState({ redraw: false }); 31 | } 32 | } else { 33 | this.setState({ redraw: false }); 34 | } 35 | 36 | if ( 37 | this.props.compare && nextProps.compare && 38 | this.props.compare.created !== nextProps.compare.created 39 | ) { 40 | this.setState({ redraw: true }); 41 | } 42 | 43 | if (this.props.unitSystem !== nextProps.unitSystem) { 44 | this.setState({ redraw: true }); 45 | } 46 | } 47 | 48 | beautifyTime(value) { 49 | let m = Math.floor(value / 60); 50 | let s = value % 60; 51 | 52 | if (s === 0) { 53 | return m; 54 | } else { 55 | // Add empty label, but show line on 30 sec intervals. 56 | return ''; 57 | } 58 | } 59 | 60 | createRoastPointsDataset(roastPoints) { 61 | return Object.keys(roastPoints).map( 62 | key => { 63 | return { 64 | x: roastPoints[key].elapsed / 60000, 65 | y: displayTemp(roastPoints[key].temperature, this.props.unitSystem) 66 | }; 67 | } 68 | ).sort((a,b) => { 69 | return a.x - b.x; 70 | }); 71 | } 72 | 73 | createRateOfRoastDataset(dataSet) { 74 | let ror = []; 75 | for (var i = 0; i < dataSet.length; i++) { 76 | if (i === 0) { 77 | ror.push({ 78 | x: dataSet[0].x, 79 | y: 0 80 | }); 81 | } else { 82 | let tangent = (dataSet[i].y - dataSet[i - 1].y) / (dataSet[i].x - dataSet[i - 1].x); 83 | ror.push({ 84 | x: dataSet[i].x, 85 | y: tangent.toFixed(2) 86 | }); 87 | } 88 | } 89 | return ror; 90 | } 91 | 92 | render() { 93 | let redraw = false; 94 | let tempMax = 270; 95 | let tempMin = 50; 96 | let tempStepSize = 10; 97 | let rateMax = 55; 98 | 99 | if (this.props.unitSystem === C.IMPERIAL) { 100 | tempMax = 520; 101 | tempMin = 120; 102 | tempStepSize = 20; 103 | rateMax = 100; 104 | } 105 | 106 | if (this.props.roastPoints) { 107 | let chartData = {}; 108 | let maxX = 14; 109 | let data = this.createRoastPointsDataset(this.props.roastPoints); 110 | let ror = this.createRateOfRoastDataset(data); 111 | 112 | // Set max x scale. 113 | if (data.length > 0) { 114 | let lastMin = data[data.length - 1].x; 115 | if (lastMin + 1 > maxX) { 116 | maxX = lastMin + 1; 117 | redraw = true; 118 | } 119 | } 120 | 121 | chartData = { 122 | labels: [], 123 | datasets: [ 124 | // Temperature points. 125 | { 126 | label: 'temp 1', 127 | data, 128 | fill: false, 129 | yAxisID: 'temp' 130 | }, 131 | // Rate of roast. 132 | { 133 | label: 'rate 1', 134 | data: ror, 135 | fill: false, 136 | yAxisID: 'rate' 137 | }, 138 | // First crack - basically adding 2 points vertically on 139 | // top of each other. 140 | { 141 | label: 'first crack 1', 142 | data: [ 143 | { x: 0, y: 0 }, 144 | { x: 0, y: 1000 } 145 | ], 146 | fill: false, 147 | yAxisID: 'temp' 148 | } 149 | ] 150 | }; 151 | 152 | if (this.props.firstCrack) { 153 | chartData.datasets[2].data = [ 154 | { x: this.props.firstCrack / 60000, y: 50 }, 155 | { x: this.props.firstCrack / 60000, y: 1000 } 156 | ]; 157 | } 158 | 159 | let compare = this.props.compare; 160 | if (compare === null) { 161 | compare = this.state.compare; 162 | } 163 | 164 | if (compare !== null) { 165 | let comparePoints = compare.roastPoints; 166 | let compareData = this.createRoastPointsDataset(comparePoints); 167 | let compareRor = this.createRateOfRoastDataset(compareData); 168 | 169 | let lastMin = compareData[compareData.length - 1].x; 170 | if (lastMin + 1 > maxX) { 171 | maxX = lastMin + 1; 172 | redraw = true; 173 | } 174 | 175 | chartData.datasets.push({ 176 | label: 'temp 2', 177 | data: compareData, 178 | fill: false, 179 | yAxisID: 'temp' 180 | }); 181 | 182 | chartData.datasets.push({ 183 | label: 'rate 2', 184 | data: compareRor, 185 | fill: false, 186 | yAxisID: 'rate' 187 | }); 188 | 189 | if (compare.firstCrack) { 190 | let compareFirstCrack = compare.firstCrack / 60000; 191 | chartData.datasets.push( 192 | { 193 | label: 'first crack 2', 194 | data: [ 195 | { x: compareFirstCrack, y: 50 }, 196 | { x: compareFirstCrack, y: 1000 } 197 | ], 198 | fill: false, 199 | yAxisID: 'temp' 200 | } 201 | ); 202 | } 203 | 204 | if (this.state.compare === null && this.state.redraw === false) { 205 | redraw = true; 206 | } 207 | } 208 | 209 | // Assign colors to each dataset. 210 | chartData.datasets.forEach((v, i) => { 211 | let color = C.CHART_COLORS[i % C.CHART_COLORS.length]; 212 | chartData.datasets[i].borderColor = color; 213 | chartData.datasets[i].backgroundColor = color; 214 | chartData.datasets[i].borderWidth = 1; 215 | }); 216 | 217 | let chartOptions = { 218 | defaultFontFamily: 'Roboto', 219 | animation: { 220 | duration: 1000 221 | }, 222 | scales: { 223 | xAxes: [{ 224 | type: 'linear', 225 | position: 'bottom', 226 | id: 'time', 227 | ticks: { 228 | max: maxX, 229 | min: 0, 230 | stepSize: 1 231 | } 232 | }], 233 | yAxes: [ 234 | { 235 | id: 'temp', 236 | type: 'linear', 237 | position: 'left', 238 | ticks: { 239 | max: tempMax, 240 | min: tempMin, 241 | stepSize: tempStepSize 242 | } 243 | }, 244 | { 245 | id: 'rate', 246 | position: 'right', 247 | type: 'linear', 248 | ticks: { 249 | max: rateMax, 250 | min: 0, 251 | stepSize: 5 252 | } 253 | }, 254 | ] 255 | }, 256 | maintainAspectRatio: true, 257 | responsive: true, 258 | tooltips: { 259 | titleFontFamily: 'Roboto', 260 | titleFontStyle: 'normal', 261 | callbacks: { 262 | title: (item, data) => { 263 | let xLabel = item[0].xLabel; 264 | let min = xLabel / 1 << 0; 265 | let sec = xLabel % 1 * 60 << 0; 266 | if (sec < 10) { 267 | sec = '0' + sec; 268 | } 269 | if (min < 10) { 270 | min = '0' + min; 271 | } 272 | return 'elapsed: ' + min + ':' + sec; 273 | } 274 | } 275 | } 276 | }; 277 | 278 | if (redraw || this.state.redraw) { 279 | return <Line 280 | data={ chartData } 281 | options={ chartOptions } 282 | width="400" 283 | height="280" 284 | redraw 285 | />; 286 | } else { 287 | return <Line 288 | data={ chartData } 289 | options={ chartOptions } 290 | width="400" 291 | height="280" 292 | />; 293 | } 294 | 295 | } else { 296 | return null; 297 | } 298 | } 299 | } 300 | 301 | export default RoastChart; 302 | -------------------------------------------------------------------------------- /src/components/RoastList.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import moment from 'moment'; 3 | 4 | import Button from './utils/Button'; 5 | import C from '../constants'; 6 | import Card from './utils/Card'; 7 | import CardAction from './utils/CardAction'; 8 | import CardContent from './utils/CardContent'; 9 | import CardTitle from './utils/CardTitle'; 10 | import Instructions from './Instructions'; 11 | import NavigationLink from '../components/utils/NavigationLink'; 12 | import history from '../history'; 13 | import { 14 | displayTemp, 15 | displayWeight 16 | } from '../helpers'; 17 | 18 | class RoastList extends React.Component { 19 | 20 | roastStatus(statusText) { 21 | switch(statusText) { 22 | case C.ROAST_PENDING: 23 | return 'pending'; 24 | break; 25 | 26 | case C.ROAST_COMPLETED: 27 | return 'completed'; 28 | break; 29 | 30 | case C.ROAST_IN_PROGRESS: 31 | return 'in progress'; 32 | break; 33 | 34 | default: 35 | return statusText; 36 | break; 37 | } 38 | } 39 | 40 | lastRoastPointDuration(roastPoints) { 41 | let duration = null; 42 | if (roastPoints) { 43 | let roastKey = Object.keys(roastPoints).pop(); 44 | duration = moment(roastPoints[roastKey].elapsed).format('mm:ss'); 45 | } 46 | 47 | return duration; 48 | } 49 | 50 | roastRows() { 51 | let content = null; 52 | 53 | if (this.props.roasts) { 54 | content = Object.keys(this.props.roasts).map(key => { 55 | let roast = this.props.roasts[key]; 56 | let roastDate = ''; 57 | 58 | if (roast.roastStart) { 59 | roastDate = moment(roast.roastStart).format('DD-MM-YY HH:mm'); 60 | } 61 | 62 | return ( 63 | <tr key={ key } 64 | onClick={(e) => { 65 | e.preventDefault(); 66 | history.push(`/roasts/${key}`); 67 | }} 68 | > 69 | <td className="bobon-table-cell--beans-name 70 | mdl-data-table__cell--non-numeric" 71 | > 72 | <strong>{ roast.beansName }</strong> 73 | </td> 74 | 75 | <td className="bobon-table-cell--hidden-mobile 76 | mdl-data-table__cell--non-numeric" 77 | > 78 | <div className={ `bobon-text-with-icon bobon-roast-status--${ roast.status.toLowerCase() }` }> 79 | <i className="material-icons">fiber_manual_record</i> 80 | { this.roastStatus(roast.status) } 81 | </div> 82 | </td> 83 | 84 | <td className="mdl-data-table__cell--non-numeric"> 85 | { roastDate } 86 | </td> 87 | 88 | <td className="bobon-table-cell--hidden-mobile "> 89 | { roast.beansMoisture } % 90 | </td> 91 | 92 | <td className="bobon-table-cell--hidden-mobile"> 93 | { displayWeight(roast.batchSize, this.props.unitSystem) } { this.props.weightUnit } 94 | </td> 95 | 96 | <td className="bobon-table-cell--hidden-mobile "> 97 | { this.lastRoastPointDuration(roast.roastPoints) } 98 | </td> 99 | 100 | <td className="bobon-table-cell--hidden-mobile "> 101 | { roast.firstCrack ? moment(roast.firstCrack).format('mm:ss') : '-' } 102 | </td> 103 | 104 | <td className="mdl-color-text--amber-500 105 | mdl-data-table__cell--non-numeric" 106 | > 107 | <button 108 | className="mdl-button mdl-js-button mdl-button--icon 109 | mdl-js-ripple-effect" 110 | onClick={ (e) => { 111 | e.preventDefault(); 112 | e.stopPropagation(); 113 | this.props.removeRoast( 114 | key, 115 | roast.beansName, 116 | roast.roastStart 117 | ); 118 | } } 119 | > 120 | <i className="material-icons">delete</i> 121 | </button> 122 | </td> 123 | </tr> 124 | ); 125 | }); 126 | } 127 | 128 | return content; 129 | } 130 | 131 | render() { 132 | let content = null; 133 | if (this.props.roasts && Object.keys(this.props.roasts).length > 0) { 134 | content = ( 135 | <div className="mdl-grid"> 136 | <Card customClass="mdl-cell mdl-cell--12-col"> 137 | 138 | <CardTitle> 139 | <div className="bobon-text-with-icon"> 140 | <i className="material-icons">timeline</i> 141 | My roasts 142 | </div> 143 | </CardTitle> 144 | 145 | <table className="mdl-data-table mdl-js-data-table 146 | bobon-util__full-width" 147 | > 148 | <thead> 149 | <tr> 150 | <th className="bobon-table-cell--beans-name 151 | mdl-data-table__cell--non-numeric" 152 | > 153 | Bean's name 154 | </th> 155 | <th className="bobon-table-cell--hidden-mobile 156 | mdl-data-table__cell--non-numeric" 157 | > 158 | Status 159 | </th> 160 | <th className="mdl-data-table__cell--non-numeric"> 161 | Roast date 162 | </th> 163 | <th className="bobon-table-cell--hidden-mobile"> 164 | Moisture 165 | </th> 166 | <th className="bobon-table-cell--hidden-mobile"> 167 | Batch Size 168 | </th> 169 | <th className="bobon-table-cell--hidden-mobile"> 170 | Duration 171 | </th> 172 | <th className="bobon-table-cell--hidden-mobile"> 173 | 1st crack 174 | </th> 175 | <th className="mdl-data-table__cell--non-numeric"> 176 | Del 177 | </th> 178 | </tr> 179 | </thead> 180 | 181 | <tbody> 182 | { this.roastRows() } 183 | </tbody> 184 | </table> 185 | </Card> 186 | </div> 187 | ); 188 | } else { 189 | content = ( 190 | <div className="mdl-grid"> 191 | <Card customClass="mdl-cell mdl-cell--12-col"> 192 | 193 | <CardTitle> 194 | <div className="bobon-text-with-icon"> 195 | <i className="material-icons">timeline</i> 196 | You have not recorded any roast profile, yet. 197 | </div> 198 | </CardTitle> 199 | 200 | <Instructions/> 201 | 202 | <CardAction> 203 | <Button customClass="mdl-button-with-icon mdl-color-text--red-500" 204 | onClick={ () => { 205 | history.push('/new'); 206 | } } 207 | > 208 | <i className="material-icons">whatshot</i> 209 | Create a new roast 210 | </Button> 211 | </CardAction> 212 | </Card> 213 | 214 | </div> 215 | ); 216 | } 217 | 218 | return content; 219 | } 220 | } 221 | 222 | export default RoastList; 223 | -------------------------------------------------------------------------------- /src/components/RoastPointInput.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import C from '../constants'; 3 | 4 | import Button from './utils/Button'; 5 | 6 | class RoastPointInput extends React.Component { 7 | 8 | componentDidUpdate() { 9 | // Upgrades all upgradable components (i.e. with 'mdl-js-*' class). 10 | componentHandler.upgradeDom(); 11 | } 12 | 13 | disabled() { 14 | if (this.props.status === C.ROAST_IN_PROGRESS) { 15 | return false; 16 | } else { 17 | return true; 18 | } 19 | } 20 | 21 | render() { 22 | if (this.props.status === C.ROAST_PENDING || 23 | this.props.status === C.ROAST_IN_PROGRESS) { 24 | return ( 25 | <form 26 | action="#" 27 | onSubmit={ e => { 28 | this.props.onSubmit(e , this.props.unitSystem) 29 | } } 30 | className="bobon-util__full-width" 31 | disabled={ this.disabled() } 32 | > 33 | <input 34 | type="hidden" 35 | id="roastId" 36 | name="roastId" 37 | value={ this.props.roastId } 38 | /> 39 | 40 | <input 41 | type="hidden" 42 | id="roastStart" 43 | name="roastStart" 44 | value={ this.props.roastStart } 45 | /> 46 | 47 | <input 48 | className="bobon-roast-temp-input" 49 | type="number" 50 | id="roastTemp" 51 | name="roastTemp" 52 | disabled={ this.disabled() } 53 | autoFocus={ !this.disabled() } 54 | /> 55 | 56 | <input 57 | type="submit" 58 | value={ "Add temperature / " + this.props.tempUnit } 59 | className="mdl-button mdl-button--colored mdl-js-button 60 | mdl-js-ripple-effect" 61 | disabled={ this.disabled() } 62 | /> 63 | </form> 64 | ); 65 | } else { 66 | return null; 67 | } 68 | } 69 | } 70 | 71 | export default RoastPointInput; 72 | -------------------------------------------------------------------------------- /src/components/RoastPointsList.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Table from './utils/Table'; 3 | 4 | class RoastPointsList extends React.Component { 5 | roastPoints() { 6 | return Object.keys(this.props.roastPoints).map( 7 | key => { 8 | return ( 9 | <tr key={ key }> 10 | <td className="mdl-data-table__cell--non-numeric"> 11 | { this.props.roastPoints[key].elapsed } 12 | </td> 13 | <td> 14 | { this.props.roastPoints[key].temperature } 15 | </td> 16 | </tr> 17 | ); 18 | } 19 | ); 20 | } 21 | 22 | render() { 23 | return ( 24 | <Table> 25 | <thead> 26 | <tr> 27 | <th className="mdl-data-table__cell--non-numeric">Time Stamp</th> 28 | <th>Temperature</th> 29 | </tr> 30 | </thead> 31 | 32 | <tbody> 33 | { this.roastPoints() } 34 | </tbody> 35 | </Table> 36 | ); 37 | } 38 | } 39 | 40 | export default RoastPointsList; 41 | -------------------------------------------------------------------------------- /src/components/RoastProfile.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import moment from 'moment'; 3 | 4 | import Button from './utils/Button'; 5 | import C from '../constants'; 6 | import Card from './utils/Card'; 7 | import CardAction from './utils/CardAction'; 8 | import CardContent from './utils/CardContent'; 9 | import CardTitle from './utils/CardTitle'; 10 | import PostRoastNoteFormContainer from '../containers/PostRoastNoteFormContainer'; 11 | import RoastChart from './RoastChart'; 12 | import RoastPointInputContainer from '../containers/RoastPointInputContainer'; 13 | import RoastPointsListContainer from '../containers/RoastPointsListContainer'; 14 | import StopWatchContainer from '../containers/StopWatchContainer'; 15 | import { 16 | displayTemp, 17 | displayWeight 18 | } from '../helpers'; 19 | 20 | class RoastProfile extends React.Component { 21 | 22 | beansMoisture() { 23 | let content = null; 24 | if (this.props.beansMoisture) { 25 | content = ( 26 | <div className="mdl-cell mdl-cell--6-col"> 27 | <div className="bobon-text-with-icon"> 28 | <i className="material-icons">opacity</i> 29 | { this.props.beansMoisture } % 30 | </div> 31 | </div> 32 | ); 33 | } 34 | 35 | return content; 36 | } 37 | 38 | stopWatch() { 39 | let content = null; 40 | 41 | if (this.props.roastInProgress && 42 | this.props.roastInProgress !== this.props.roastId 43 | ) { 44 | content = null; 45 | } else if (this.props.status === C.ROAST_PENDING || 46 | this.props.status === C.ROAST_IN_PROGRESS 47 | ) { 48 | content = ( 49 | <div className="mdl-cell mdl-cell--12-col"> 50 | <StopWatchContainer 51 | roastId={ this.props.roastId } 52 | roastStart={ this.props.roastStart } 53 | status={ this.props.status } 54 | /> 55 | </div> 56 | ); 57 | } 58 | 59 | return content; 60 | } 61 | 62 | tempInput() { 63 | let content = null; 64 | if (this.props.roastInProgress && 65 | this.props.roastInProgress !== this.props.roastId 66 | ) { 67 | content = null; 68 | } else if ( 69 | this.props.status === C.ROAST_PENDING || 70 | this.props.status === C.ROAST_IN_PROGRESS 71 | ) { 72 | content = ( 73 | <div className="mdl-cell mdl-cell--12-col"> 74 | <Card customClass="bobon-util__full-width"> 75 | <CardTitle> 76 | <div className="bobon-text-with-icon"> 77 | <i className="material-icons">add_circle</i> 78 | Temperature input / { this.props.tempUnit } 79 | </div> 80 | </CardTitle> 81 | 82 | <RoastPointInputContainer 83 | roastId={ this.props.roastId } 84 | roastStart={ this.props.roastStart } 85 | status={ this.props.status } 86 | addFirstCrack={ this.props.addFirstCrack } 87 | undoTemperature={ this.props.undoTemperature } 88 | /> 89 | 90 | <CardAction> 91 | <Button customClass="mdl-button-with-icon" 92 | onClick={() => { 93 | this.props.undoLastTemperature(this.props.roastId, this.props.roastPoints); 94 | } } 95 | disabled={ this.props.status === C.ROAST_IN_PROGRESS && 96 | Object.keys(this.props.roastPoints).length > 1 ? false : true } 97 | > 98 | <i className="material-icons">replay</i> 99 | Undo 100 | </Button> 101 | 102 | <Button customClass="mdl-button-with-icon mdl-color-text--red-500" 103 | onClick={() => { 104 | this.props.addFirstCrack(this.props.roastId, this.props.roastStart); 105 | } } 106 | disabled={ this.props.status === C.ROAST_IN_PROGRESS ? false : true } 107 | > 108 | <i className="material-icons">whatshot</i> 109 | First Crack! 110 | </Button> 111 | </CardAction> 112 | </Card> 113 | </div> 114 | ); 115 | } 116 | 117 | return content; 118 | } 119 | 120 | postRoastNote() { 121 | let content = null; 122 | 123 | if (this.props.status === C.ROAST_COMPLETED) { 124 | content = ( 125 | <PostRoastNoteFormContainer 126 | roastId={ this.props.roastId } 127 | status={ this.props.status } 128 | /> 129 | ); 130 | } 131 | 132 | return content; 133 | } 134 | 135 | roastNote() { 136 | return( 137 | <Card customClass="mdl-cell mdl-cell--6-col mdl-cell--12-col-tablet"> 138 | <CardTitle> 139 | <div className="bobon-text-with-icon"> 140 | <i className="material-icons">description</i> 141 | Roasting Notes 142 | </div> 143 | </CardTitle> 144 | <CardContent> 145 | <plaintext> 146 | { this.props.roastNote } 147 | </plaintext> 148 | </CardContent> 149 | </Card> 150 | ); 151 | } 152 | 153 | roastPointsList() { 154 | return ( 155 | <Card customClassName="mdl-cell mdl-cell--6-col"> 156 | <CardTitle> 157 | <h2 className="mdl-card__title-text">Temperature points</h2> 158 | </CardTitle> 159 | <CardContent> 160 | <RoastPointsListContainer roastId={ this.props.roastId }/> 161 | </CardContent> 162 | </Card> 163 | ); 164 | } 165 | 166 | magicButton() { 167 | return ( 168 | <button onClick={ () => { 169 | let uid = C.FIREBASE.auth().currentUser.uid; 170 | let ref = C.FIREBASE.database().ref(`roasts/${uid}/${this.props.roastId}/status`); 171 | 172 | ref.set(C.ROAST_PENDING); 173 | } } 174 | > 175 | Magic button (PENDING) 176 | </button> 177 | ); 178 | } 179 | 180 | roastTime() { 181 | if (this.props.status === C.ROAST_PENDING) { 182 | return null; 183 | } else { 184 | return ( 185 | <div className="mdl-cell mdl-cell--6-col"> 186 | <div className="bobon-text-with-icon"> 187 | <i className="material-icons">event</i> 188 | { moment(this.props.roastStart).format('DD/MM/YYYY - h:mm') } 189 | </div> 190 | </div> 191 | ); 192 | } 193 | } 194 | 195 | status() { 196 | let content = null; 197 | let statusText = ''; 198 | 199 | if (this.props.status) { 200 | 201 | switch (this.props.status) { 202 | case C.ROAST_PENDING: 203 | statusText = 'Pending'; 204 | break; 205 | 206 | case C.ROAST_IN_PROGRESS: 207 | statusText = 'In progress'; 208 | break; 209 | 210 | default: 211 | statusText = 'Completed'; 212 | break; 213 | } 214 | 215 | content = ( 216 | <div className="mdl-cell mdl-cell--6-col"> 217 | <div className={ `bobon-text-with-icon bobon-roast-status--${ this.props.status.toLowerCase() }` }> 218 | <i className="material-icons">fiber_manual_record</i> 219 | { statusText } 220 | </div> 221 | </div> 222 | ); 223 | } 224 | 225 | return content; 226 | } 227 | 228 | selectCompare() { 229 | if (typeof this.props.roastIds === 'undefined') { 230 | return null; 231 | } 232 | 233 | let roastIdList = this.props.roastIds.map(roast => { 234 | return ( 235 | <li className="mdl-menu__item" 236 | onClick={ () => { 237 | this.props.compareRoasts(this.props.roastId, roast.id); 238 | } } 239 | > 240 | { '[' + moment(roast.roastStart).format('DD/MM/YYYY HH:mm') + '] ' + roast.value } 241 | </li> 242 | ); 243 | }); 244 | 245 | let buttonText = "Compare with a previous roast profile"; 246 | 247 | if (this.props.compare) { 248 | buttonText = '[' + moment(this.props.compare.roastStart).format('DD/MM/YYYY HH:mm') + '] ' + this.props.compare.beansName; 249 | } 250 | 251 | return ( 252 | <div className="mdl-cell mdl-cell--12-col mdl-cell--12-col-tablet"> 253 | <button id="select-compare" 254 | className="mdl-button mdl-js-button mdl-button-colored mdl-color--indigo-500 mdl-color-text--white mdl-button-with-icon" 255 | > 256 | <i className="material-icons">assessment</i> 257 | { buttonText } 258 | </button> 259 | <ul className="bobon-list__compare-select mdl-menu mdl-js-menu mdl-js-ripple-effect" 260 | htmlFor="select-compare" 261 | > 262 | { roastIdList } 263 | </ul> 264 | </div> 265 | ); 266 | } 267 | 268 | lastRoastPointId() { 269 | if (this.props.roastPoints) { 270 | return Object.keys(this.props.roastPoints).pop(); 271 | } else { 272 | return null; 273 | } 274 | } 275 | 276 | roastDuration() { 277 | let min = '00'; 278 | let sec = '00'; 279 | 280 | if (this.props.hasOwnProperty('roastPoints')) { 281 | let duration = this.props.roastPoints[this.lastRoastPointId()].elapsed; 282 | min = duration / 60000 << 0; 283 | sec = duration / 1000 % 60 << 0; 284 | 285 | if (min < 10) { 286 | min = '0' + min; 287 | } 288 | 289 | if (sec < 10) { 290 | sec = '0' + sec; 291 | } 292 | } 293 | 294 | return `${min}:${sec}`; 295 | } 296 | 297 | render() { 298 | return ( 299 | <div className="mdl-grid"> 300 | 301 | <div className="bobon-chart-title mdl-cell mdl-cell--12-col"> 302 | <div className="bobon-text-with-icon"> 303 | <i className="material-icons">assessment</i> 304 | { this.props.beansName } 305 | </div> 306 | </div> 307 | 308 | 309 | <div className="mdl-cell mdl-cell--8-col mdl-cell--12-col-tablet"> 310 | <RoastChart 311 | roastPoints={ this.props.roastPoints } 312 | beansName={ this.props.beansName } 313 | roastStart={ this.props.roastStart } 314 | compare={ this.props.compare } 315 | firstCrack={ this.props.firstCrack } 316 | unitSystem={ this.props.unitSystem } 317 | /> 318 | </div> 319 | 320 | <div className="mdl-cell mdl-cel--4-col mdl-grid mdl-cell--12-col-tablet 321 | bobon-roast-profile-sidebar" 322 | > 323 | { this.selectCompare() } 324 | { this.stopWatch() } 325 | { this.tempInput() } 326 | 327 | <Card customClass="mdl-cell mdl-cell--12-col"> 328 | <CardTitle> 329 | <div className="bobon-text-with-icon"> 330 | <i className="material-icons">playlist_add_check</i> 331 | Roast details 332 | </div> 333 | </CardTitle> 334 | <CardContent customClass="mdl-grid bobon-util__full-width"> 335 | <div className="mdl-cell mdl-cell--6-col"> 336 | <div className="bobon-text-with-icon"> 337 | <i className="material-icons">label</i> 338 | { this.props.beansName } 339 | </div> 340 | </div> 341 | 342 | { this.status() } 343 | { this.roastTime() } 344 | 345 | <div className="mdl-cell mdl-cell--6-col"> 346 | <div className="bobon-text-with-icon"> 347 | <i className="material-icons">alarm</i> 348 | { this.roastDuration() } 349 | </div> 350 | </div> 351 | 352 | <div className="mdl-cell mdl-cell--6-col"> 353 | <div className="bobon-text-with-icon"> 354 | <i className="material-icons">shopping_basket</i> 355 | { displayWeight( 356 | this.props.batchSize, 357 | this.props.unitSystem 358 | ) } { this.props.weightUnit } 359 | </div> 360 | </div> 361 | 362 | { this.beansMoisture() } 363 | 364 | </CardContent> 365 | </Card> 366 | 367 | { this.roastNote() } 368 | { this.postRoastNote() } 369 | 370 | </div> 371 | </div> 372 | ); 373 | } 374 | } 375 | 376 | export default RoastProfile; 377 | -------------------------------------------------------------------------------- /src/components/Settings.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import C from '../constants'; 4 | import Card from './utils/Card'; 5 | import CardTitle from './utils/CardTitle'; 6 | import CardAction from './utils/CardAction'; 7 | import CardContent from './utils/CardContent'; 8 | import Radio from './utils/Radio'; 9 | 10 | class Settings extends React.Component { 11 | shouldComponentUpdate(nextProps) { 12 | return nextProps.unitSystem !== this.props.unitSystem; 13 | } 14 | 15 | render() { 16 | return ( 17 | <Card customClass="mdl-cell mdl-cell--12-col"> 18 | <CardTitle> 19 | <div className="bobon-text-with-icon"> 20 | <i className="material-icons">settings</i> 21 | Settings 22 | </div> 23 | </CardTitle> 24 | <CardTitle> 25 | <div className="bobon-text-with-icon"> 26 | <i className="material-icons">straighten</i> 27 | Unit System 28 | </div> 29 | </CardTitle> 30 | <CardContent> 31 | 32 | <form onChange={ e => { 33 | this.props.onChangeUnitSystem(e, this.props.unitSystem); 34 | } } 35 | > 36 | <Radio 37 | htmlFor="unit-system-metric" 38 | name="unitSystem" 39 | value={ C.METRIC } 40 | label="°C - kg" 41 | checked={ this.props.unitSystem === C.METRIC } 42 | /> 43 | 44 | <Radio 45 | htmlFor="unit-system-imperial" 46 | name="unitSystem" 47 | value={ C.IMPERIAL } 48 | label="°F - lbs" 49 | checked={ this.props.unitSystem === C.IMPERIAL } 50 | /> 51 | </form> 52 | 53 | </CardContent> 54 | </Card> 55 | ); 56 | } 57 | } 58 | 59 | export default Settings; 60 | -------------------------------------------------------------------------------- /src/components/Spinner.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Spinner = () => ( 4 | <div className="bobon-spinner mdl-spinner mdl-js-spinner is-active"></div> 5 | ); 6 | 7 | export default Spinner; 8 | -------------------------------------------------------------------------------- /src/components/StopWatch.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import C from '../constants'; 3 | 4 | import Button from './utils/Button'; 5 | import Card from './utils/Card'; 6 | import CardTitle from './utils/CardTitle'; 7 | import CardAction from './utils/CardAction'; 8 | import CardContent from './utils/CardContent'; 9 | 10 | class StopWatch extends React.Component { 11 | 12 | constructor(props) { 13 | super(props); 14 | this.state = { 15 | elapsed: 0 16 | }; 17 | } 18 | 19 | componentWillMount() { 20 | // If already in progress, continue stopwatch. 21 | if (this.props.status === C.ROAST_IN_PROGRESS) { 22 | this.props.resumeStopWatch( 23 | this.props.roastId, 24 | this.props.roastStart, 25 | setInterval(() => { 26 | this.setState({ 27 | elapsed: Date.now() - this.props.roastStart 28 | }); 29 | }, 100) 30 | ); 31 | } 32 | } 33 | 34 | 35 | startButton() { 36 | let content = null; 37 | 38 | if (this.props.status === C.ROAST_PENDING) { 39 | content = ( 40 | <Button customClass="mdl-button-with-icon mdl-color-text--red-500" 41 | onClick={ () => { 42 | let roastStart = Date.now(); 43 | this.props.startStopWatch( 44 | this.props.roastId, 45 | roastStart, 46 | setInterval(() => { 47 | this.setState({ 48 | elapsed: Date.now() - this.props.roastStart 49 | }); 50 | }, 100) 51 | ); 52 | } } 53 | > 54 | <i className="material-icons">fiber_manual_record</i> 55 | START 56 | </Button> 57 | ); 58 | } 59 | 60 | return content; 61 | } 62 | 63 | stopButton() { 64 | let content = null; 65 | 66 | if (this.props.status === C.ROAST_IN_PROGRESS) { 67 | content = ( 68 | <Button customClass="mdl-button-width-icon mdl-colo--text-red-500" 69 | onClick={ () => { 70 | this.props.stopStopWatch( 71 | this.props.roastId, 72 | this.props.tick 73 | ); 74 | } } 75 | > 76 | <i className="material-icons">stop</i> 77 | STOP 78 | </Button> 79 | ); 80 | } 81 | 82 | return content; 83 | } 84 | 85 | currentElapsedTime() { 86 | let t = this.state.elapsed; 87 | let min = (t / 1000 / 60) << 0; 88 | let sec = (t / 1000) % 60 << 0; 89 | let fsec = (t % 1000) / 100 << 0; 90 | 91 | if (min < 10) { 92 | min = '0' + min; 93 | } 94 | 95 | if (sec < 10) { 96 | sec = '0' + sec; 97 | } 98 | 99 | return `${ min } : ${ sec } : ${ fsec }`; 100 | } 101 | 102 | render() { 103 | let content = null; 104 | if ( 105 | this.props.status === C.ROAST_PENDING || 106 | this.props.status === C.ROAST_IN_PROGRESS 107 | ) { 108 | content = ( 109 | <Card customClass="bobon-util__full-width"> 110 | <CardTitle> 111 | <div className="bobon-text-with-icon"> 112 | <i className="material-icons">timer</i> 113 | Timer 114 | </div> 115 | </CardTitle> 116 | <CardContent customClass="bobon-stopwatch-time"> 117 | { this.currentElapsedTime() } 118 | </CardContent> 119 | <CardAction> 120 | { this.startButton() } 121 | { this.stopButton() } 122 | </CardAction> 123 | </Card> 124 | ); 125 | } 126 | 127 | return content; 128 | } 129 | } 130 | 131 | export default StopWatch; 132 | -------------------------------------------------------------------------------- /src/components/UnitSwitcher.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | class UnitSwitcher extends React.Component { 4 | render() { 5 | let switcherId = this.props.customId ? this.props.customId : "bobon-unit-switcher"; 6 | 7 | return ( 8 | <div className={ 9 | "bobon-unit-switcher-menu" + 10 | (this.props.customClass ? ' ' + this.props.customClass : '') } 11 | > 12 | <button 13 | id={ switcherId } 14 | className="mdl-button mdl-js-ripple-effect mdl-js-button 15 | mdl-button-with-icon" 16 | > 17 | <i className="material-icons">straighten</i> 18 | { this.props.tempUnit } - { this.props.weightUnit } 19 | </button> 20 | 21 | <ul 22 | className="mdl-menu mdl-menu--bottom-left 23 | mdl-js-menu mdl-js-ripple-effect" 24 | htmlFor={ switcherId } 25 | > 26 | <li 27 | className="mdl-menu__item mdl-button mdl-button-with-icon" 28 | onClick={ () => { 29 | this.props.updateUnitSystem(this.props.altUnitSystem, this.props.unitSystem); 30 | } } 31 | > 32 | <i className="material-icons">straighten</i> 33 | { this.props.altTempUnit } - { this.props.altWeightUnit } 34 | </li> 35 | </ul> 36 | </div> 37 | ); 38 | } 39 | } 40 | 41 | export default UnitSwitcher; 42 | -------------------------------------------------------------------------------- /src/components/utils/Button.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | class Button extends React.Component { 4 | 5 | render() { 6 | if (this.props.disabled === true) { 7 | 8 | return ( 9 | <button 10 | className={ `mdl-button mdl-js-button mdl-js-ripple-effect mdl-button--colored ${ this.props.customClass }` } 11 | id={ this.props.id ? this.props.id : '' } 12 | disabled 13 | > 14 | { this.props.children } 15 | </button> 16 | ); 17 | 18 | } else { 19 | 20 | return ( 21 | <button 22 | className={ `mdl-button mdl-js-button mdl-js-ripple-effect mdl-button--colored ${ this.props.customClass }` } 23 | onClick={ this.props.onClick } 24 | id={ this.props.id ? this.props.id : '' } 25 | > 26 | { this.props.children } 27 | </button> 28 | ); 29 | 30 | } 31 | } 32 | } 33 | 34 | export default Button; 35 | -------------------------------------------------------------------------------- /src/components/utils/Card.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | class Card extends React.Component { 4 | render() { 5 | return ( 6 | <div className={ `mdl-card ${ this.props.customClass }` }> 7 | { this.props.children } 8 | </div> 9 | ); 10 | } 11 | } 12 | 13 | export default Card; 14 | -------------------------------------------------------------------------------- /src/components/utils/CardAction.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | class CardAction extends React.Component { 4 | render() { 5 | return( 6 | <div className="mdl-card__action"> 7 | { this.props.children } 8 | </div> 9 | ); 10 | } 11 | } 12 | 13 | export default CardAction; 14 | -------------------------------------------------------------------------------- /src/components/utils/CardContent.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | class CardContent extends React.Component { 4 | render() { 5 | return( 6 | <div className={ `mdl-card__supporting-text ${ this.props.customClass ? this.props.customClass : '' }` }> 7 | { this.props.children } 8 | </div> 9 | ); 10 | } 11 | } 12 | 13 | export default CardContent; 14 | -------------------------------------------------------------------------------- /src/components/utils/CardTitle.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | class CardTitle extends React.Component { 4 | render() { 5 | return ( 6 | <div className={ `mdl-card__title ${ this.props.customClass }` }> 7 | { this.props.children } 8 | </div> 9 | ); 10 | } 11 | } 12 | 13 | export default CardTitle; 14 | -------------------------------------------------------------------------------- /src/components/utils/NavigationLink.jsx: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import history from '../../history'; 3 | 4 | class NavigationLink extends React.Component { 5 | 6 | static propTypes() { 7 | return { 8 | path: PropTypes.string.isRequired 9 | }; 10 | } 11 | 12 | render() { 13 | return( 14 | <a onClick={ (e) => { 15 | e.preventDefault(); 16 | history.push(this.props.path); 17 | } } 18 | > 19 | { this.props.children } 20 | </a> 21 | ); 22 | } 23 | } 24 | 25 | export default NavigationLink; 26 | -------------------------------------------------------------------------------- /src/components/utils/Radio.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | class Radio extends React.Component { 4 | 5 | shouldComponentUpdate(nextProps) { 6 | return nextProps.checked !== this.props.checked; 7 | } 8 | 9 | radioInput() { 10 | let content = null; 11 | 12 | if (this.props.checked === true) { 13 | content = <input 14 | className="mdl-radio__button" 15 | id={ this.props.htmlFor } 16 | name={ this.props.name } 17 | type="radio" 18 | value={ this.props.value } 19 | checked 20 | /> 21 | } else { 22 | content = <input 23 | className="mdl-radio__button" 24 | id={ this.props.htmlFor } 25 | name={ this.props.name } 26 | type="radio" 27 | value={ this.props.value } 28 | /> 29 | } 30 | 31 | return content; 32 | } 33 | 34 | render() { 35 | return ( 36 | <label 37 | className={ `mdl-radio bobon-radio mdl-js-radio mdl-js-ripple-effect ${ this.props.customClass ? this.props.customClass : '' }` } 38 | htmlFor={ this.props.htmlFor } 39 | > 40 | { this.radioInput() } 41 | <span className="mdl-radio__label"> 42 | { this.props.label } 43 | </span> 44 | </label> 45 | ); 46 | } 47 | } 48 | 49 | export default Radio ; 50 | -------------------------------------------------------------------------------- /src/components/utils/Table.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | class Table extends React.Component { 4 | render() { 5 | return ( 6 | <table className={ `mdl-data-table mdl-js-data-table ${ this.props.customClass }` }> 7 | { this.props.children } 8 | </table> 9 | ); 10 | } 11 | } 12 | 13 | export default Table; 14 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | import firebase from 'firebase'; 2 | 3 | const firebaseConf = { 4 | apiKey: "AIzaSyAHHNXxfL04NxxSYBIawr1qG15p6L9gex0", 5 | authDomain: "bobonroast.firebaseapp.com", 6 | databaseURL: "https://bobonroast.firebaseio.com", 7 | storageBucket: "bobonroast.appspot.com" 8 | }; 9 | firebase.initializeApp(firebaseConf); 10 | 11 | const C = { 12 | // Auth actions. 13 | LOGIN_REQUEST: 'LOGIN_REQUEST', 14 | LOGIN_SUCCESS: 'LOGIN_SUCCESS', 15 | LOGIN_FAILED: 'LOGIN_FAILED', 16 | LOGOUT: 'LOGOUT', 17 | LISTENING_TO_AUTH: 'LISTENING_TO_AUTH', 18 | 19 | // Roasts actions. 20 | FETCHING_ROASTS: 'FETCHING_ROASTS', 21 | FETCHED_ROASTS: 'FETCHED_ROASTS', 22 | COMPARE_ROASTS: 'COMPARE_ROASTS', 23 | ADD_FIRST_CRACK: 'ADD_FIRST_CRACK', 24 | 25 | CREATING_NEW_ROAST: 'CREATING_NEW_ROAST', 26 | REMOVE_ROAST: 'REMOVE_ROAST', 27 | CREATE_NEW_ROAST_SUCCESS: 'CREATE_NEW_ROAST_SUCCESS', 28 | CREATE_NEW_ROAST_FAILED: 'CREATE_NEW_ROAST_FAILED', 29 | UPDATE_ROAST_VALUE: 'UPDATE_ROAST_VALUE', 30 | UPDATE_CURRENT_ROAST_VALUE: 'UPDATE_CURRENT_ROAST_VALUE', 31 | TOGGLE_EDITING_FIELD: 'TOGGLE_EDITING_FIELD', 32 | 33 | // Editing types. 34 | FIELD_POST_ROAST_NOTE: 'FIELD_POST_ROAST_NOTE', 35 | 36 | // Editing field staatus. 37 | FIELD_STATUS_LOADING: 'FIELD_STATUS_LOADING', 38 | FIELD_STATUS_EDITING: 'FIELD_STATUS_EDITING', 39 | FIELD_STATUS_NOT_EDITING: 'FIELD_STATUS_NOT_EDITING', 40 | 41 | // Roasting actions. 42 | ROAST_START: 'ROAST_START', 43 | CHECK_ROAST_IN_PROGRESS: 'CHECK_ROAST_IN_PROGRESS', 44 | CLEAR_ROAST_IN_PROGRESS: 'CLEAR_ROAST_IN_PROGRESS', 45 | 46 | // StopWatch actions. 47 | STOPWATCH_START: 'STOPWATCH_START', 48 | STOPWATCH_TICK: 'STOPWATCH_TICK', 49 | STOPWATCH_STOP: 'STOPWATCH_STOP', 50 | STOPWATCH_RESUME: 'STOPWATCH_STOP', 51 | 52 | // New roast status. 53 | ROAST_UNSAVED: 'ROAST_UNSAVED', 54 | ROAST_PENDING: 'ROAST_PENDING', 55 | ROAST_CREATED: 'ROAST_CREATED', 56 | ROAST_IN_PROGRESS: 'ROAST_IN_PROGRESS', 57 | ROAST_COMPLETED: 'ROAST_COMPLETED', 58 | 59 | // Auth states. 60 | LOGGED_IN: 'LOGGED_IN', 61 | LOGGING_IN: 'LOGGING_IN', 62 | LOGGED_OUT: 'LOGGED_OUT', 63 | 64 | // Dialog. 65 | SHOW_DIALOG: 'SHOW_DIALOG', 66 | CLEAR_DIALOG: 'CLEAR_DIALOG', 67 | 68 | // MISC. 69 | FIREBASE: firebase, 70 | CHART_COLORS: ['#B71C1C', '#F9A825', '#AD1457', '#00796B', '#26C6DA', '#388E3C'], 71 | 72 | // Data loading. 73 | LOADING_DATA: 'LOADING_DATA', 74 | LOADED_DATA: 'LOADED_DATA', 75 | 76 | // Google analytics 77 | GOOGLE_ANALYTICS_CODE: 'UA-79419231-1', 78 | 79 | // Settings actions. 80 | UPDATE_SETTING: 'UPDATE_SETTING', 81 | FETCHED_SETTINGS: 'FETCHED_SETTINGS', 82 | 83 | // Settings. 84 | METRIC: 'METRIC', 85 | IMPERIAL: 'IMPERIAL', 86 | METRIC_TEMP_SYMBOL: '°C', 87 | METRIC_WEIGHT_SYMBOL: 'kg', 88 | IMPERIAL_TEMP_SYMBOL: '°F', 89 | IMPERIAL_WEIGHT_SYMBOL: 'lbs' 90 | }; 91 | 92 | export default C; 93 | -------------------------------------------------------------------------------- /src/containers/AppContainer.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import App from '../components/App'; 3 | 4 | const mapStateToProps = (state, ownProps) => { 5 | return { 6 | ...ownProps, 7 | authStatus: state.auth.authStatus, 8 | dataLoading: state.dataLoading 9 | }; 10 | }; 11 | 12 | const AppContainer = connect(mapStateToProps)(App); 13 | 14 | export default AppContainer; 15 | -------------------------------------------------------------------------------- /src/containers/DialogContainer.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import Dialog from '../components/Dialog'; 3 | import { clearDialog } from '../actions'; 4 | 5 | const mapStateToProps = state => { 6 | return { 7 | dialogType: state.dialog.dialogType, 8 | noAction: state.dialog.noAction, 9 | noText: state.dialog.noText, 10 | text: state.dialog.text, 11 | yesAction: state.dialog.yesAction, 12 | yesText: state.dialog.yesText 13 | }; 14 | }; 15 | 16 | const mapDispatchToProps = dispatch => { 17 | return { 18 | clearDialog: () => { 19 | dispatch(clearDialog()); 20 | } 21 | }; 22 | } 23 | 24 | const DialogContainer = connect( 25 | mapStateToProps, 26 | mapDispatchToProps 27 | )(Dialog); 28 | 29 | export default DialogContainer; 30 | -------------------------------------------------------------------------------- /src/containers/DrawerContainer.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import Drawer from '../components/Drawer'; 3 | 4 | const mapStateToProps = state => { 5 | return { 6 | photoURL: state.auth.photoURL, 7 | userName: state.auth.userName, 8 | authStatus: state.auth.authStatus 9 | }; 10 | }; 11 | 12 | const DrawerContainer = connect(mapStateToProps)(Drawer); 13 | 14 | export default DrawerContainer; 15 | -------------------------------------------------------------------------------- /src/containers/HeaderContainer.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import Header from '../components/Header'; 3 | import { logout } from '../actions'; 4 | import history from '../history'; 5 | 6 | import C from '../constants'; 7 | 8 | const mapStateToProps = (state, ownProps) => { 9 | return { 10 | authStatus: state.auth.authStatus, 11 | location: ownProps.location, 12 | photoURL: state.auth.photoURL, 13 | roastInProgress: state.roastInProgress, 14 | userName: state.auth.userName, 15 | email: state.auth.email 16 | }; 17 | }; 18 | 19 | const mapDispatchToProps = dispatch => { 20 | return { 21 | logout: e => { 22 | console.log(e); 23 | e.preventDefault(); 24 | C.FIREBASE.auth().signOut().then(() => { 25 | dispatch(logout()); 26 | location.reload(); 27 | }); 28 | } 29 | }; 30 | } 31 | 32 | const HeaderContainer = connect( 33 | mapStateToProps, 34 | mapDispatchToProps 35 | )(Header); 36 | 37 | export default HeaderContainer; 38 | -------------------------------------------------------------------------------- /src/containers/LoginFormContainer.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import LoginForm from '../components/LoginForm'; 3 | import { loginRequest, loginSuccess } from '../actions'; 4 | import auth from '../auth'; 5 | import firebase from 'firebase'; 6 | import C from '../constants'; 7 | 8 | const mapStateToProps = state => { 9 | return { 10 | authStatus: state.auth.authStatus 11 | }; 12 | }; 13 | 14 | const mapDispatchToProps = dispatch => { 15 | return { 16 | onLoginBtnClick: (e, method) => { 17 | e.preventDefault(); 18 | dispatch(loginRequest()); 19 | 20 | let authProvider = null; 21 | 22 | switch (method) { 23 | case 'facebook': 24 | authProvider = new firebase.auth.FacebookAuthProvider(); 25 | break; 26 | 27 | case 'google': 28 | authProvider = new firebase.auth.GoogleAuthProvider(); 29 | break; 30 | 31 | default: 32 | break; 33 | } 34 | 35 | if (window.location.protocol === 'http') { 36 | C.FIREBASE.auth().signInWithPopup(authProvider); 37 | } else { 38 | C.FIREBASE.auth().signInWithRedirect(authProvider).then(v => { 39 | console.log(v); 40 | }); 41 | } 42 | } 43 | }; 44 | }; 45 | 46 | const LoginFormContainer = connect( 47 | mapStateToProps, 48 | mapDispatchToProps 49 | )(LoginForm); 50 | 51 | export default LoginFormContainer; 52 | -------------------------------------------------------------------------------- /src/containers/MainContainer.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import Main from '../components/Main'; 3 | 4 | const mapStateToProps = state => { 5 | return { 6 | authStatus: state.auth.authStatus 7 | }; 8 | }; 9 | 10 | const MainContainer = connect(mapStateToProps)(Main); 11 | 12 | export default MainContainer; 13 | -------------------------------------------------------------------------------- /src/containers/NewRoastFormContainer.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | 3 | import C from '../constants'; 4 | import NewRoastForm from '../components/NewRoastForm'; 5 | import history from '../history'; 6 | import { 7 | createNewRoast, 8 | createNewRoastFailed, 9 | updateCurrentRoastValue, 10 | createNewRoastSuccess 11 | } from '../actions'; 12 | import { 13 | metricTemp, 14 | metricWeight 15 | } from '../helpers'; 16 | 17 | const mapStateToProps = state => { 18 | const roast = state.newRoast; 19 | let disabled = true; 20 | let tempUnit = '°C'; 21 | let weightUnit = 'kg'; 22 | 23 | if (state.settings.unitSystem === C.IMPERIAL) { 24 | tempUnit = '°F'; 25 | weightUnit = 'lbs'; 26 | } 27 | 28 | if (roast.initialTemp && roast.beansName && roast.batchSize) { 29 | disabled = false; 30 | } 31 | 32 | return { 33 | batchSize: roast.batchSize || '', 34 | beansMoisture: roast.beansMoisture || '', 35 | beansName: roast.beansName || '', 36 | disabled, 37 | initialTemp: roast.initialTemp || '', 38 | processing: roast.status === C.ROAST_PENDING, 39 | roastInProgress: state.roastInProgress, 40 | roastNote: roast.roastNote || '', 41 | uid: state.auth.uid, 42 | tempUnit, 43 | weightUnit, 44 | unitSystem: state.settings.unitSystem 45 | }; 46 | }; 47 | 48 | const mapDispatchToProps = dispatch => { 49 | return { 50 | onSubmit: e => { 51 | e.preventDefault(); 52 | 53 | const f = e.target; 54 | const unitSystem = f.unitSystem.value; 55 | const roastNote = f.roastNote.value; 56 | const uid = f.uid.value; 57 | const beansName = f.beansName.value; 58 | const beansMoisture = f.beansMoisture.value ? parseFloat(f.beansMoisture.value) : ''; 59 | const batchSize = metricWeight( 60 | parseFloat(f.batchSize.value), 61 | unitSystem 62 | ); 63 | const initialTemp = metricTemp( 64 | parseFloat(f.initialTemp.value), 65 | unitSystem 66 | ); 67 | 68 | // Always convert to metric in database. 69 | if (beansName !== '' && batchSize !== '' && initialTemp !== '' && uid !== '') { 70 | dispatch(createNewRoast({ 71 | roastNote, 72 | beansName, 73 | batchSize, 74 | beansMoisture, 75 | initialTemp, 76 | uid 77 | })); 78 | 79 | let roastRef = C.FIREBASE.database().ref(`/roasts/${uid}`); 80 | 81 | roastRef.push({ 82 | created: Date.now(), 83 | status: C.ROAST_PENDING, 84 | roastStart: 0, 85 | initialTemp, 86 | roastNote, 87 | beansMoisture, 88 | beansName, 89 | batchSize, 90 | roastPoints: [], 91 | uid 92 | }, err => { 93 | dispatch(createNewRoastFailed(err)); 94 | }).then((newRoast) => { 95 | dispatch(createNewRoastSuccess(newRoast)); 96 | 97 | // Create initial roast point. 98 | let ref = C.FIREBASE.app().database().ref(`roasts/${uid}/${newRoast.key}/roastPoints`); 99 | 100 | ref.push({ 101 | elapsed: 0, 102 | temperature: initialTemp 103 | }); 104 | 105 | history.push(`roasts/${newRoast.key}`) 106 | }); 107 | } 108 | }, 109 | 110 | onChange: e => { 111 | dispatch(updateCurrentRoastValue(e.target.id, e.target.value)); 112 | } 113 | }; 114 | }; 115 | 116 | const NewRoastFormContainer = connect( 117 | mapStateToProps, 118 | mapDispatchToProps 119 | )(NewRoastForm); 120 | 121 | export default NewRoastFormContainer; 122 | -------------------------------------------------------------------------------- /src/containers/PostRoastNoteFormContainer.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import C from '../constants'; 3 | import { updateRoastValue, toggleEditing } from '../actions'; 4 | import PostRoastNoteForm from '../components/PostRoastNoteForm'; 5 | 6 | const mapStateToProps = (state, ownProps) => { 7 | let postRoastNote = ''; 8 | let editings = state.editingFields[ownProps.roastId] || {}; 9 | let editing = C.FIELD_STATUS_LOADING; 10 | 11 | if (state.roasts[ownProps.roastId]) { 12 | postRoastNote = state.roasts[ownProps.roastId].postRoastNote; 13 | } 14 | 15 | return { 16 | editing: editings[C.FIELD_POST_ROAST_NOTE] || C.FIELD_STATUS_NOT_EDITING, 17 | postRoastNote, 18 | roastId: ownProps.roastId 19 | }; 20 | }; 21 | 22 | const mapDispatchToProps = dispatch => { 23 | return { 24 | onSubmit: e => { 25 | 26 | e.preventDefault(); 27 | 28 | let roastId = e.target.roastId.value; 29 | let postRoastNote = e.target.postRoastNote.value; 30 | let uid = C.FIREBASE.auth().currentUser.uid; 31 | let ref = C.FIREBASE.database().ref(`roasts/${uid}/${roastId}/postRoastNote`); 32 | 33 | ref.set(postRoastNote, () => { 34 | dispatch(updateRoastValue( 35 | roastId, 36 | C.FIELD_POST_ROAST_NOTE, 37 | postRoastNote 38 | )); 39 | 40 | 41 | dispatch(toggleEditing(roastId, C.FIELD_POST_ROAST_NOTE)); 42 | }); 43 | }, 44 | 45 | toggleEditing: (roastId, field) => { 46 | dispatch(toggleEditing(roastId, field)); 47 | } 48 | }; 49 | }; 50 | 51 | const PostRoastNoteFormContainer = connect( 52 | mapStateToProps, 53 | mapDispatchToProps 54 | )(PostRoastNoteForm); 55 | 56 | export default PostRoastNoteFormContainer; 57 | -------------------------------------------------------------------------------- /src/containers/RoastListContainer.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import RoastList from '../components/RoastList'; 3 | import { removeRoast } from '../actions'; 4 | import C from '../constants'; 5 | import { showDialog } from '../actions'; 6 | import moment from 'moment'; 7 | 8 | const mapStateToProps = state => { 9 | let unitSystem = state.settings.unitSystem; 10 | return { 11 | roasts: state.roasts, 12 | unitSystem, 13 | tempUnit: unitSystem === C.IMPERIAL ? '°F' : '°C', 14 | weightUnit: unitSystem === C.IMPERIAL ? 'lbs' : 'kg' 15 | }; 16 | }; 17 | 18 | const mapDispatchToProps = dispatch => { 19 | return { 20 | removeRoast: (roastId, beansName) => { 21 | dispatch(showDialog({ 22 | yesAction: () => { 23 | let uid = C.FIREBASE.auth().currentUser.uid; 24 | let ref = C.FIREBASE.app().database().ref(`roasts/${uid}/${roastId}`); 25 | 26 | ref.remove(() => { 27 | dispatch(removeRoast(roastId)); 28 | }); 29 | }, 30 | text: `Are you sure to delete "${ beansName }"? This cannot be undone.` 31 | })); 32 | } 33 | }; 34 | }; 35 | 36 | const RoastListContainer = connect( 37 | mapStateToProps, 38 | mapDispatchToProps 39 | )(RoastList); 40 | 41 | export default RoastListContainer; 42 | -------------------------------------------------------------------------------- /src/containers/RoastPointInputContainer.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | 3 | import C from '../constants'; 4 | import RoastPointInput from '../components/RoastPointInput'; 5 | import { metricTemp } from '../helpers'; 6 | 7 | const mapStateToProps = (state, ownProps) => { 8 | let unitSystem = state.settings.unitSystem; 9 | return { 10 | roastId: ownProps.roastId, 11 | roastStart: ownProps.roastStart, 12 | status: ownProps.status, 13 | unitSystem, 14 | tempUnit: unitSystem === C.IMPERIAL ? C.IMPERIAL_TEMP_SYMBOL : C.METRIC_TEMP_SYMBOL 15 | }; 16 | }; 17 | 18 | const mapDispatchToProps = dispatch => { 19 | return { 20 | onSubmit: (e, unitSystem) => { 21 | e.preventDefault(); 22 | 23 | const temp = metricTemp(parseFloat(e.target.roastTemp.value), unitSystem); 24 | const roastId = e.target.roastId.value; 25 | const elapsed = Date.now() - parseFloat(e.target.roastStart.value); 26 | const uid = C.FIREBASE.auth().currentUser.uid; 27 | const ref = C.FIREBASE.app().database().ref(`roasts/${uid}/${roastId}/roastPoints`); 28 | 29 | ref.push({ 30 | temperature: temp, 31 | elapsed 32 | }); 33 | 34 | e.target.roastTemp.value = ''; 35 | } 36 | }; 37 | }; 38 | 39 | const RoastPointInputContainer = connect( 40 | mapStateToProps, 41 | mapDispatchToProps 42 | )(RoastPointInput); 43 | 44 | export default RoastPointInputContainer; 45 | -------------------------------------------------------------------------------- /src/containers/RoastPointsListContainer.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import RoastPointsList from '../components/RoastPointsList'; 3 | 4 | const mapStateToProps = (state, ownProps) => { 5 | let roastId = ownProps.roastId; 6 | let roastPoints = {}; 7 | 8 | if (state.roasts.hasOwnProperty(roastId) && 9 | state.roasts[roastId].hasOwnProperty('roastPoints') 10 | ) { 11 | roastPoints = state.roasts[roastId].roastPoints; 12 | } 13 | 14 | return { 15 | roastPoints 16 | }; 17 | }; 18 | 19 | const RoastPointsListContainer = connect( 20 | mapStateToProps 21 | )(RoastPointsList); 22 | 23 | export default RoastPointsListContainer; 24 | -------------------------------------------------------------------------------- /src/containers/RoastProfileContainer.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import RoastProfile from '../components/RoastProfile'; 3 | import { compareRoasts, addFirstCrack } from '../actions'; 4 | import C from '../constants'; 5 | 6 | const mapStateToProps = (state, ownProps) => { 7 | if (typeof state.roasts[ownProps.params.roastId] !== 'undefined') { 8 | const unitSystem = state.settings.unitSystem; 9 | let compare = null; 10 | 11 | if (state.roasts[ownProps.params.roastId].hasOwnProperty('compare') && 12 | state.roasts[ownProps.params.roastId].compare 13 | ) { 14 | compare = state.roasts[state.roasts[ownProps.params.roastId].compare]; 15 | } 16 | 17 | return { 18 | ...state.roasts[ownProps.params.roastId], 19 | roastId: ownProps.params.roastId, 20 | roastInProgress: state.roastInProgress, 21 | compare, 22 | unitSystem, 23 | tempUnit: unitSystem === C.IMPERIAL ? C.IMPERIAL_TEMP_SYMBOL : C.METRIC_TEMP_SYMBOL, 24 | weightUnit: unitSystem === C.IMPERIAL ? C.IMPERIAL_WEIGHT_SYMBOL : C.METRIC_WEIGHT_SYMBOL, 25 | roastIds: Object.keys(state.roasts).map(roastId => { 26 | return { 27 | id: roastId, 28 | value: state.roasts[roastId].beansName, 29 | roastStart: state.roasts[roastId].roastStart 30 | }; 31 | }).filter(v => { 32 | return v.id !== ownProps.params.roastId; 33 | }) 34 | }; 35 | } else { 36 | return {}; 37 | } 38 | }; 39 | 40 | const getLastRoastPointId = roastPoints => { 41 | if (roastPoints) { 42 | return Object.keys(roastPoints).pop(); 43 | } else { 44 | return null; 45 | } 46 | }; 47 | 48 | const mapDispatchToProps = dispatch => { 49 | return { 50 | onChangeCompare: (e, roastId) => { 51 | dispatch(compareRoasts(roastId, e.target.value)); 52 | }, 53 | compareRoasts: (roastId, compareId) => { 54 | dispatch(compareRoasts(roastId, compareId)); 55 | }, 56 | undoLastTemperature: (roastId, roastPoints) => { 57 | // Only remove points if there are more than 1 roast points. 58 | // The initial temperature point should never be removed. 59 | if (roastPoints && Object.keys(roastPoints).length > 1) { 60 | let lastRoastPointId = getLastRoastPointId(roastPoints); 61 | let uid = C.FIREBASE.auth().currentUser.uid; 62 | let ref = C.FIREBASE.app().database().ref(`/roasts/${uid}/${roastId}/roastPoints/${lastRoastPointId}`); 63 | ref.remove(); 64 | } 65 | }, 66 | addFirstCrack: (roastId, roastStart) => { 67 | let uid = C.FIREBASE.auth().currentUser.uid; 68 | let ref = C.FIREBASE.app().database().ref(`/roasts/${uid}/${roastId}/firstCrack`); 69 | let firstCrackTime = Date.now() - roastStart; 70 | dispatch(addFirstCrack(roastId, firstCrackTime)); 71 | ref.set(firstCrackTime); 72 | } 73 | }; 74 | }; 75 | 76 | const RoastProfileContainer = connect( 77 | mapStateToProps, 78 | mapDispatchToProps 79 | )(RoastProfile); 80 | 81 | export default RoastProfileContainer; 82 | -------------------------------------------------------------------------------- /src/containers/SettingsContainer.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | 3 | import Settings from '../components/Settings'; 4 | import { updateSetting } from '../actions'; 5 | import C from '../constants'; 6 | 7 | const mapStateToProps = (state, ownProps) => { 8 | return { 9 | unitSystem: state.settings.unitSystem 10 | }; 11 | }; 12 | 13 | const mapDispatchToProps = dispatch => { 14 | return { 15 | onChangeUnitSystem: (e, currentUnitSystem) => { 16 | // Only update if the value changes. 17 | if (e.target.value !== currentUnitSystem) { 18 | let uid = C.FIREBASE.auth().currentUser.uid; 19 | let ref = C.FIREBASE.app().database().ref(`settings/${uid}/unitSystem`); 20 | let value = e.target.value; 21 | 22 | ref.set(value, err => { 23 | if (err) { 24 | console.error(err); 25 | } 26 | }); 27 | } 28 | } 29 | }; 30 | }; 31 | 32 | const SettingsContainer = connect( 33 | mapStateToProps, 34 | mapDispatchToProps 35 | )(Settings); 36 | 37 | export default SettingsContainer; 38 | -------------------------------------------------------------------------------- /src/containers/StopWatchContainer.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import { startStopWatch, resumeStopWatch, tickStopWatch, stopStopWatch, clearRoastInProgress } from '../actions'; 3 | import StopWatch from '../components/StopWatch'; 4 | 5 | const mapStateToProps = (state, ownProps) => { 6 | return { 7 | elapsed: state.stopWatch.elapsed, 8 | roastId: ownProps.roastId, 9 | roastStart: ownProps.roastStart, 10 | status: ownProps.status, 11 | tick: state.stopWatch.tick 12 | }; 13 | }; 14 | 15 | const mapDispatchToProps = dispatch => { 16 | return { 17 | startStopWatch: (roastId, roastStart, tick) => { 18 | dispatch(startStopWatch(roastId, roastStart, tick)); 19 | }, 20 | 21 | resumeStopWatch: (roastId, roastStart, tick) => { 22 | dispatch(resumeStopWatch(roastId, roastStart, tick)); 23 | }, 24 | 25 | stopStopWatch: (roastId, tick) => { 26 | clearInterval(tick); 27 | dispatch(stopStopWatch(roastId)); 28 | } 29 | }; 30 | }; 31 | 32 | const StopWatchContainer = connect(mapStateToProps, mapDispatchToProps)(StopWatch); 33 | 34 | export default StopWatchContainer; 35 | -------------------------------------------------------------------------------- /src/containers/UnitSwitcherContainer.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | 3 | import C from '../constants'; 4 | import UnitSwitcher from '../components/UnitSwitcher'; 5 | 6 | const mapStateToProps = (state, ownProps) => { 7 | let unitSystem = state.settings.unitSystem; 8 | return { 9 | unitSystem, 10 | altUnitSystem: unitSystem === C.IMPERIAL ? C.METRIC : C.IMPERIAL, 11 | tempUnit: unitSystem === C.IMPERIAL ? C.IMPERIAL_TEMP_SYMBOL : C.METRIC_TEMP_SYMBOL, 12 | weightUnit: unitSystem === C.IMPERIAL ? C.IMPERIAL_WEIGHT_SYMBOL : C.METRIC_WEIGHT_SYMBOL, 13 | altTempUnit: unitSystem === C.IMPERIAL ? C.METRIC_TEMP_SYMBOL : C.IMPERIAL_TEMP_SYMBOL, 14 | altWeightUnit: unitSystem === C.IMPERIAL ? C.METRIC_WEIGHT_SYMBOL : C.IMPERIAL_WEIGHT_SYMBOL, 15 | customClass: ownProps.customClass 16 | }; 17 | }; 18 | 19 | const mapDispatchToProps = dispatch => { 20 | return { 21 | updateUnitSystem: (unitSystem, currentUnitSystem) => { 22 | if (unitSystem !== currentUnitSystem) { 23 | const uid = C.FIREBASE.auth().currentUser.uid; 24 | const ref = C.FIREBASE.app().database().ref(`/settings/${ uid }/unitSystem`); 25 | ref.set(unitSystem, err => { 26 | if (err) { 27 | console.error(err); 28 | } 29 | }); 30 | } 31 | } 32 | }; 33 | }; 34 | 35 | const UnitSwitcherContainer = connect( 36 | mapStateToProps, 37 | mapDispatchToProps 38 | )(UnitSwitcher); 39 | 40 | export default UnitSwitcherContainer; 41 | -------------------------------------------------------------------------------- /src/demoData.js: -------------------------------------------------------------------------------- 1 | export const demoDataset = { 2 | "labels":[], 3 | "datasets":[ 4 | {"label":"temp 1","data":[{"x":0,"y":150},{"x":0.39416666666666667,"y":137},{"x":0.6643,"y":130},{"x":0.82205,"y":125},{"x":1.0293666666666668,"y":120},{"x":1.4826833333333334,"y":110},{"x":1.78335,"y":105},{"x":1.9583,"y":104},{"x":2.109383333333333,"y":101},{"x":2.386433333333333,"y":100},{"x":2.7411166666666666,"y":101},{"x":3.10165,"y":105},{"x":3.5406833333333334,"y":110},{"x":3.935033333333333,"y":115},{"x":4.254833333333333,"y":120},{"x":4.64535,"y":125},{"x":5.0022,"y":130},{"x":5.331666666666667,"y":135},{"x":5.69025,"y":140},{"x":5.9647,"y":145},{"x":6.289883333333333,"y":150},{"x":6.628333333333333,"y":155},{"x":6.991483333333333,"y":160},{"x":7.361516666666667,"y":165},{"x":7.900833333333333,"y":170},{"x":8.356983333333334,"y":175},{"x":8.84215,"y":180},{"x":9.2757,"y":185},{"x":9.769883333333333,"y":190},{"x":10.20775,"y":195},{"x":10.635633333333333,"y":200},{"x":11.014816666666666,"y":205},{"x":11.445233333333332,"y":210},{"x":11.8819,"y":215},{"x":12.250016666666667,"y":216},{"x":13.099216666666667,"y":212}],"fill":false,"yAxisID":"temp","borderColor":"rgba(183,28,28,0.4)","backgroundColor":"rgba(183,28,28,0.4)","borderWidth":1},{"label":"rate 1","data":[{"x":0,"y":0},{"x":0.39416666666666667,"y":"-32.98"},{"x":0.6643,"y":"-25.91"},{"x":0.82205,"y":"-31.70"},{"x":1.0293666666666668,"y":"-24.12"},{"x":1.4826833333333334,"y":"-22.06"},{"x":1.78335,"y":"-16.63"},{"x":1.9583,"y":"-5.72"},{"x":2.109383333333333,"y":"-19.86"},{"x":2.386433333333333,"y":"-3.61"},{"x":2.7411166666666666,"y":"2.82"},{"x":3.10165,"y":"11.09"},{"x":3.5406833333333334,"y":"11.39"},{"x":3.935033333333333,"y":"12.68"},{"x":4.254833333333333,"y":"15.63"},{"x":4.64535,"y":"12.80"},{"x":5.0022,"y":"14.01"},{"x":5.331666666666667,"y":"15.18"},{"x":5.69025,"y":"13.94"},{"x":5.9647,"y":"18.22"},{"x":6.289883333333333,"y":"15.38"},{"x":6.628333333333333,"y":"14.77"},{"x":6.991483333333333,"y":"13.77"},{"x":7.361516666666667,"y":"13.51"},{"x":7.900833333333333,"y":"9.27"},{"x":8.356983333333334,"y":"10.96"},{"x":8.84215,"y":"10.31"},{"x":9.2757,"y":"11.53"},{"x":9.769883333333333,"y":"10.12"},{"x":10.20775,"y":"11.42"},{"x":10.635633333333333,"y":"11.69"},{"x":11.014816666666666,"y":"13.19"},{"x":11.445233333333332,"y":"11.62"},{"x":11.8819,"y":"11.45"},{"x":12.250016666666667,"y":"2.72"},{"x":13.099216666666667,"y":"-4.71"}],"fill":false,"yAxisID":"rate","fill": true,"borderColor":"rgba(249,168,37,0.4)","backgroundColor":"rgba(249,168,37,0.4)","borderWidth":1},{"label":"first crack 1","data":[{"x":11.204766666666666,"y":50},{"x":11.204766666666666,"y":1000}],"fill":false,"yAxisID":"temp","fill": true,"borderColor":"#AD1457","backgroundColor":"#AD1457","borderWidth":1},{"label":"temp 2","data":[{"x":0,"y":150},{"x":0.60815,"y":125},{"x":1.2954666666666668,"y":110},{"x":1.66715,"y":107},{"x":2.28505,"y":107},{"x":2.5820333333333334,"y":106},{"x":3.4804833333333334,"y":110},{"x":3.9413666666666667,"y":115},{"x":4.418933333333333,"y":120},{"x":4.787316666666666,"y":126},{"x":5.7703,"y":140},{"x":6.165016666666666,"y":150},{"x":6.567033333333334,"y":155},{"x":6.8451,"y":160},{"x":7.836616666666667,"y":175},{"x":8.264933333333333,"y":180},{"x":8.595533333333334,"y":185},{"x":8.961233333333332,"y":190},{"x":9.322966666666666,"y":195},{"x":9.685816666666666,"y":200},{"x":10.03865,"y":205},{"x":10.4486,"y":210},{"x":10.86455,"y":215},{"x":11.5202,"y":219},{"x":11.8839,"y":220}],"fill":false,"yAxisID":"temp","borderColor":"rgba(0,121,107,0.4)","backgroundColor":"rgba(0,121,107,0.4)","borderWidth":1},{"label":"rate 2","data":[{"x":0,"y":0},{"x":0.60815,"y":"-41.11"},{"x":1.2954666666666668,"y":"-21.82"},{"x":1.66715,"y":"-8.07"},{"x":2.28505,"y":"0.00"},{"x":2.5820333333333334,"y":"-3.37"},{"x":3.4804833333333334,"y":"4.45"},{"x":3.9413666666666667,"y":"10.85"},{"x":4.418933333333333,"y":"10.47"},{"x":4.787316666666666,"y":"16.29"},{"x":5.7703,"y":"14.24"},{"x":6.165016666666666,"y":"25.33"},{"x":6.567033333333334,"y":"12.44"},{"x":6.8451,"y":"17.98"},{"x":7.836616666666667,"y":"15.13"},{"x":8.264933333333333,"y":"11.67"},{"x":8.595533333333334,"y":"15.12"},{"x":8.961233333333332,"y":"13.67"},{"x":9.322966666666666,"y":"13.82"},{"x":9.685816666666666,"y":"13.78"},{"x":10.03865,"y":"14.17"},{"x":10.4486,"y":"12.20"},{"x":10.86455,"y":"12.02"},{"x":11.5202,"y":"6.10"},{"x":11.8839,"y":"2.75"}],"fill":false,"yAxisID":"rate","fill": true,"borderColor":"rgba(38,198,218,0.4)","backgroundColor":"rgba(38,198,218,0.4)","borderWidth":1},{"label":"first crack 2","data":[{"x":10.182066666666667,"y":50},{"x":10.182066666666667,"y":1000}],"fill":false,"yAxisID":"temp","fill": true,"borderColor":"#388E3C","backgroundColor":"#388E3C","borderWidth":1}]}; 5 | 6 | export const demoChartOptions = { 7 | "defaultFontFamily":"Roboto", 8 | "scales": { 9 | "xAxes": [ 10 | { 11 | "type":"linear", 12 | "position":"bottom", 13 | "id":"time", 14 | "ticks": { 15 | "max":12.099216666666667, 16 | "min":0, 17 | "stepSize":1 18 | } 19 | } 20 | ], 21 | "yAxes": [ 22 | { 23 | "id":"temp", 24 | "type":"linear", 25 | "position":"left", 26 | "ticks": { 27 | "max":270, 28 | "min":50, 29 | "stepSize":10 30 | } 31 | }, 32 | { 33 | "id":"rate", 34 | "position":"right", 35 | "type":"linear", 36 | "ticks": { 37 | "max":55, 38 | "min":0, 39 | "stepSize":5 40 | } 41 | } 42 | ] 43 | }, 44 | "maintainAspectRatio": true, 45 | "responsive": false, 46 | "tooltips": { 47 | "titleFontFamily":"Roboto", 48 | "titleFontStyle":"normal", 49 | "callbacks": { 50 | title: (item, data) => { 51 | let xLabel = item[0].xLabel; 52 | let min = xLabel / 1 << 0; 53 | let sec = xLabel % 1 * 60 << 0; 54 | if (sec < 10) { 55 | sec = '0' + sec; 56 | } 57 | if (min < 10) { 58 | min = '0' + min; 59 | } 60 | return 'elapsed: ' + min + ':' + sec; 61 | } 62 | } 63 | } 64 | }; 65 | -------------------------------------------------------------------------------- /src/helpers.js: -------------------------------------------------------------------------------- 1 | import C from './constants'; 2 | 3 | // Data is stored in database in metric form. 4 | export const metricWeight = (weight, unitSystem) => { 5 | if (unitSystem === C.IMPERIAL) { 6 | return weight * 0.453592; 7 | } else { 8 | return weight; 9 | } 10 | }; 11 | 12 | export const metricTemp = (temp, unitSystem) => { 13 | if (unitSystem === C.IMPERIAL) { 14 | return (temp - 32) / 1.8; 15 | } else { 16 | return temp; 17 | } 18 | }; 19 | 20 | // When displaying data, convert from metric to imperial when needed. 21 | export const displayTemp = (temp, unitSystem, fixed = true) => { 22 | let result = temp; 23 | if (unitSystem === C.IMPERIAL) { 24 | result = temp * 1.8 + 32; 25 | } 26 | return fixed ? result.toFixed(2) : result; 27 | }; 28 | 29 | export const displayWeight = (weight, unitSystem, fixed = true) => { 30 | let result = weight; 31 | if (unitSystem === C.IMPERIAL) { 32 | result = weight / 0.454592; 33 | } 34 | return fixed ? result.toFixed(2) : result; 35 | }; 36 | -------------------------------------------------------------------------------- /src/history.js: -------------------------------------------------------------------------------- 1 | import { hashHistory } from 'react-router'; 2 | //import { browserHistory } from 'react-router'; 3 | 4 | const history = hashHistory; 5 | //const history = browserHistory; 6 | 7 | export default history; 8 | -------------------------------------------------------------------------------- /src/reducers/authReducer.js: -------------------------------------------------------------------------------- 1 | import C from '../constants'; 2 | import history from '../history'; 3 | 4 | const initialState = { 5 | authProvider: null, 6 | authStatus: C.LOGGING_IN, 7 | email: null, 8 | listeningToAuth: false, 9 | photoURL: null, 10 | uid: null, 11 | userName: null 12 | }; 13 | 14 | const authReducer = (currentState = initialState, action) => { 15 | 16 | switch(action.type) { 17 | 18 | case C.LISTENING_TO_AUTH: 19 | return { 20 | ...currentState, 21 | listeningToAuth: true 22 | }; 23 | break; 24 | 25 | case C.LOGIN_REQUEST: 26 | return { 27 | ...currentState, 28 | authStatus: C.LOGGING_IN 29 | }; 30 | break; 31 | 32 | case C.LOGIN_SUCCESS: 33 | return { 34 | ...currentState, 35 | authStatus: C.LOGGED_IN, 36 | uid: action.user.uid, 37 | photoURL: action.user.photoURL, 38 | userName: action.user.displayName, 39 | email: action.user.email 40 | }; 41 | break; 42 | 43 | case C.LOGOUT: 44 | return { 45 | ...currentState, 46 | authStatus: C.LOGGED_OUT, 47 | uid: null, 48 | userName: null, 49 | photoURL: null, 50 | userName: null, 51 | email: null 52 | }; 53 | break; 54 | 55 | default: 56 | return currentState; 57 | } 58 | }; 59 | 60 | export default authReducer; 61 | -------------------------------------------------------------------------------- /src/reducers/dataLoadingReducer.js: -------------------------------------------------------------------------------- 1 | import C from '../constants'; 2 | import history from '../history'; 3 | 4 | const initialState = false; 5 | 6 | const dataLoadingReducer = (currentState = initialState, action) => { 7 | 8 | switch(action.type) { 9 | 10 | case C.LOADING_DATA: 11 | return true; 12 | break; 13 | 14 | case C.LOADED_DATA: 15 | default: 16 | return false; 17 | break; 18 | } 19 | }; 20 | 21 | export default dataLoadingReducer; 22 | -------------------------------------------------------------------------------- /src/reducers/dialogReducer.js: -------------------------------------------------------------------------------- 1 | import C from '../constants'; 2 | import history from '../history'; 3 | 4 | const initialState = { 5 | text: null, 6 | yesAction: null, 7 | noAction: null, 8 | yesText: null, 9 | noText: null, 10 | dialogType: null 11 | }; 12 | 13 | const dialogReducer = (currentState = initialState, action) => { 14 | 15 | switch(action.type) { 16 | case C.SHOW_DIALOG: 17 | return { 18 | dialogType: action.dialogType, 19 | noAction: action.noAction, 20 | noText: action.noText || 'No', 21 | text: action.text, 22 | yesAction: action.yesAction, 23 | yesText: action.yesText || 'Yes' 24 | }; 25 | break; 26 | 27 | case C.CLEAR_DIALOG: 28 | return initialState; 29 | break; 30 | 31 | default: 32 | return initialState; 33 | break; 34 | } 35 | } 36 | 37 | export default dialogReducer; 38 | -------------------------------------------------------------------------------- /src/reducers/editingFieldReducer.js: -------------------------------------------------------------------------------- 1 | import C from '../constants'; 2 | 3 | const initialState = {}; 4 | 5 | const editingFieldReducer = (currentState = initialState, action) => { 6 | switch(action.type) { 7 | 8 | case C.TOGGLE_EDITING_FIELD: 9 | let editing = C.FIELD_STATUS_EDITING; 10 | let roastFields = {}; 11 | let editingFields = currentState; 12 | 13 | if (editingFields.hasOwnProperty(action.roastId)) { 14 | roastFields = editingFields[action.roastId]; 15 | if (roastFields.hasOwnProperty(action.field)) { 16 | if (roastFields[action.field] === C.FIELD_STATUS_EDITING) { 17 | editing = C.FIELD_STATUS_NOT_EDITING; 18 | } else if (roastFields[action.field] === C.FIELD_STATUS_NOT_EDITING) { 19 | editing = C.FIELD_STATUS_EDITING; 20 | } 21 | } 22 | } 23 | 24 | return { 25 | ...currentState, 26 | [action.roastId]: { 27 | ...roastFields, 28 | [action.field]: editing 29 | } 30 | }; 31 | break; 32 | 33 | default: 34 | return currentState; 35 | break; 36 | } 37 | }; 38 | 39 | export default editingFieldReducer; 40 | -------------------------------------------------------------------------------- /src/reducers/index.js: -------------------------------------------------------------------------------- 1 | import authReducer from './authReducer'; 2 | import dataLoadingReducer from './dataLoadingReducer'; 3 | import dialogReducer from './dialogReducer'; 4 | import editingFieldReducer from './editingFieldReducer'; 5 | import newRoastReducer from './newRoastReducer'; 6 | import roastInProgressReducer from './roastInProgressReducer'; 7 | import roastsReducer from './roastsReducer'; 8 | import settingsReducer from './settingsReducer'; 9 | import stopWatchReducer from './stopWatchReducer'; 10 | import { combineReducers } from 'redux'; 11 | 12 | export default combineReducers({ 13 | auth: authReducer, 14 | dataLoading: dataLoadingReducer, 15 | dialog: dialogReducer, 16 | editingFields: editingFieldReducer, 17 | newRoast: newRoastReducer, 18 | roastInProgress: roastInProgressReducer, 19 | roasts: roastsReducer, 20 | settings: settingsReducer, 21 | stopWatch: stopWatchReducer 22 | }); 23 | -------------------------------------------------------------------------------- /src/reducers/newRoastReducer.js: -------------------------------------------------------------------------------- 1 | import C from '../constants'; 2 | import history from '../history'; 3 | 4 | const initialState = { 5 | // New roast details. 6 | roastNote: null, 7 | beansName: null, 8 | batchSize: null, 9 | moisture: null, 10 | 11 | // Roast monitor. 12 | status: C.ROAST_UNSAVED 13 | }; 14 | 15 | const newRoastReducer = (currentState = initialState, action) => { 16 | switch(action.type) { 17 | 18 | case C.CREATING_NEW_ROAST: 19 | return { 20 | ...currentState, 21 | status: C.ROAST_PENDING 22 | }; 23 | break; 24 | 25 | case C.CREATE_NEW_ROAST_SUCCESS: 26 | return initialState; 27 | break; 28 | 29 | case C.CREATE_NEW_ROAST_FAILED: 30 | return { 31 | ...currentState, 32 | status: C.ROAST_UNSAVED 33 | }; 34 | break; 35 | 36 | case C.UPDATE_CURRENT_ROAST_VALUE: 37 | return { 38 | ...currentState, 39 | [action.field]: action.value 40 | }; 41 | break; 42 | 43 | default: 44 | return currentState; 45 | } 46 | }; 47 | 48 | export default newRoastReducer; 49 | -------------------------------------------------------------------------------- /src/reducers/roastInProgressReducer.js: -------------------------------------------------------------------------------- 1 | import C from '../constants'; 2 | 3 | const initialState = null; 4 | 5 | const roastInProgressReducer = (currentState = initialState, action) => { 6 | switch(action.type) { 7 | 8 | case C.CHECK_ROAST_IN_PROGRESS: 9 | if (action.roasts) { 10 | let roastId = currentState; 11 | let roastInProgress = Object.keys(action.roasts).find(key => { 12 | return action.roasts[key].status === C.ROAST_IN_PROGRESS; 13 | }); 14 | 15 | if (roastInProgress) { 16 | roastId = roastInProgress; 17 | } else { 18 | roastId = initialState; 19 | } 20 | 21 | return roastId; 22 | } else { 23 | return null; 24 | } 25 | break; 26 | 27 | case C.CLEAR_ROAST_IN_PROGRESS: 28 | return initialState; 29 | break; 30 | 31 | default: 32 | return currentState; 33 | } 34 | }; 35 | 36 | export default roastInProgressReducer; 37 | -------------------------------------------------------------------------------- /src/reducers/roastsReducer.js: -------------------------------------------------------------------------------- 1 | import C from '../constants'; 2 | import history from '../history'; 3 | 4 | const initialState = {}; 5 | 6 | const roastsReducer = (currentState = initialState, action) => { 7 | switch(action.type) { 8 | 9 | case C.FETCHED_ROASTS: 10 | let inversedRoasts = {}; 11 | if (action.roasts) { 12 | Object.keys(action.roasts).sort((a, b)=> { 13 | return action.roasts[b].created - action.roasts[a].created; 14 | }).forEach(key => { 15 | inversedRoasts[key] = action.roasts[key]; 16 | }); 17 | } 18 | return inversedRoasts; 19 | break; 20 | 21 | case C.COMPARE_ROASTS: 22 | return { 23 | ...currentState, 24 | [action.roastId]: { 25 | ...currentState[action.roastId], 26 | compare: action.compareId 27 | } 28 | }; 29 | break; 30 | 31 | case C.ADD_FIRST_CRACK: 32 | return { 33 | ...currentState, 34 | [action.roastId]: { 35 | ...currentState[action.roastId], 36 | firstCrack: action.firstCrackTime 37 | } 38 | }; 39 | break; 40 | 41 | default: 42 | return currentState; 43 | } 44 | }; 45 | 46 | export default roastsReducer; 47 | -------------------------------------------------------------------------------- /src/reducers/settingsReducer.js: -------------------------------------------------------------------------------- 1 | import C from '../constants'; 2 | 3 | const initialState = { 4 | unitSystem: C.METRIC 5 | }; 6 | 7 | let settingsReducer = (currentState = initialState, action) => { 8 | switch (action.type) { 9 | case C.UPDATE_SETTING: 10 | return { 11 | ...currentState, 12 | [action.setting]: action.value 13 | }; 14 | break; 15 | 16 | case C.FETCHED_SETTINGS: 17 | return { 18 | ...currentState, 19 | ...action.settings 20 | }; 21 | break; 22 | 23 | default: 24 | return currentState; 25 | break; 26 | } 27 | } 28 | 29 | export default settingsReducer; 30 | -------------------------------------------------------------------------------- /src/reducers/stopWatchReducer.js: -------------------------------------------------------------------------------- 1 | import C from '../constants'; 2 | 3 | const initialState = { 4 | roastStart: null, 5 | tick: null 6 | }; 7 | 8 | const stopWatchReducer = (currentState = initialState, action) => { 9 | switch(action.type) { 10 | 11 | case C.STOPWATCH_START: 12 | case C.STOPWATCH_RESUME: 13 | return { 14 | roastStart: action.roastStart, 15 | tick: action.tick 16 | }; 17 | break; 18 | 19 | case C.STOPWATCH_STOP: 20 | return initialState; 21 | break; 22 | 23 | default: 24 | return currentState; 25 | } 26 | }; 27 | 28 | export default stopWatchReducer; 29 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | var webpack = require('webpack'); 2 | var path = require('path'); 3 | var srcPath = path.resolve(__dirname, 'src'); 4 | 5 | module.exports = { 6 | entry: { 7 | bundle: path.join(srcPath, 'app.jsx'), 8 | common: ['react', 'react-router', 'redux', 'react-redux', 'moment'] 9 | }, 10 | 11 | output: { 12 | path: path.resolve(__dirname, 'public'), 13 | filename: '[name].js', 14 | publicPath: '/' 15 | }, 16 | 17 | resolve: { 18 | extensions: ['', '.jsx', '.js'], 19 | modulesDirectories: ['node_modules', 'bower_components'] 20 | }, 21 | 22 | module: { 23 | loaders: [ 24 | { 25 | loader: 'babel', 26 | exclude: /(node_modules|bower_components)/ 27 | }, 28 | { 29 | test: /\.(jpg|png)$/, 30 | loaders: ['url?limit=25000'], 31 | include: path.resolve(__dirname, 'images') 32 | }, 33 | { 34 | test: /\.scss$/, 35 | loaders: [ 36 | 'style', 37 | 'css?sourceMap&-restructuring&aggressiveMerging', 38 | 'autoprefixer', 39 | 'sass?sourceMap' 40 | ] 41 | } 42 | ] 43 | }, 44 | 45 | plugins: [ 46 | new webpack.optimize.DedupePlugin(), 47 | new webpack.optimize.OccurrenceOrderPlugin(), 48 | new webpack.optimize.CommonsChunkPlugin( 49 | { 50 | name: "common", 51 | minChunks: 2 52 | } 53 | ), 54 | new webpack.DefinePlugin({ 55 | 'process.env': { 56 | 'NODE_ENV': JSON.stringify('production') 57 | } 58 | }) 59 | ] 60 | }; 61 | --------------------------------------------------------------------------------