├── .gitignore ├── .gitmodules ├── .travis.yml ├── Cakefile ├── LICENSE ├── README.md ├── app ├── assets │ ├── css │ │ ├── lib │ │ │ ├── accordion.less │ │ │ ├── alerts.less │ │ │ ├── bootstrap.less │ │ │ ├── breadcrumbs.less │ │ │ ├── button-groups.less │ │ │ ├── buttons.less │ │ │ ├── carousel.less │ │ │ ├── close.less │ │ │ ├── code.less │ │ │ ├── component-animations.less │ │ │ ├── dropdowns.less │ │ │ ├── forms.less │ │ │ ├── grid.less │ │ │ ├── hero-unit.less │ │ │ ├── labels-badges.less │ │ │ ├── layouts.less │ │ │ ├── mixins.less │ │ │ ├── modals.less │ │ │ ├── navbar.less │ │ │ ├── navs.less │ │ │ ├── pager.less │ │ │ ├── pagination.less │ │ │ ├── patterns.less │ │ │ ├── popovers.less │ │ │ ├── progress-bars.less │ │ │ ├── reset.less │ │ │ ├── responsive-1200px-min.less │ │ │ ├── responsive-767px-max.less │ │ │ ├── responsive-768px-979px.less │ │ │ ├── responsive-navbar.less │ │ │ ├── responsive-utilities.less │ │ │ ├── responsive.less │ │ │ ├── scaffolding.less │ │ │ ├── sprites.less │ │ │ ├── tables.less │ │ │ ├── thumbnails.less │ │ │ ├── tooltip.less │ │ │ ├── type.less │ │ │ ├── utilities.less │ │ │ ├── variables.less │ │ │ └── wells.less │ │ └── main.css.less │ └── js │ │ ├── bootstrap.js │ │ ├── bootstrap │ │ ├── bootstrap-affix.js │ │ ├── bootstrap-alert.js │ │ ├── bootstrap-button.js │ │ ├── bootstrap-carousel.js │ │ ├── bootstrap-collapse.js │ │ ├── bootstrap-dropdown.js │ │ ├── bootstrap-modal.js │ │ ├── bootstrap-popover.js │ │ ├── bootstrap-scrollspy.js │ │ ├── bootstrap-tab.js │ │ ├── bootstrap-tooltip.js │ │ ├── bootstrap-transition.js │ │ └── bootstrap-typeahead.js │ │ ├── home.js │ │ ├── html5-shiv.js │ │ ├── jquery-1.8.2.js │ │ ├── jsonlint.js │ │ └── topics.js ├── controllers │ ├── .gitkeep │ ├── home.coffee │ ├── http_api.coffee │ ├── mqtt_api.coffee │ └── websocket_api.coffee ├── helpers │ ├── .gitkeep │ ├── asset_pipeline.coffee │ ├── json.coffee │ ├── markdown.coffee │ └── notest.coffee ├── models │ ├── .gitkeep │ └── data.coffee └── views │ ├── home.hbs │ ├── layout.hbs │ ├── network_button.hbs │ └── topic.hbs ├── benchmarks ├── bench_http_mqtt_multiple_pub.coffee ├── bench_http_mqtt_multiple_sub.coffee ├── bench_mqtt.coffee ├── haproxy.cfg └── mqtt_client_pool.coffee ├── features ├── homepage.feature ├── http_pub_sub.feature ├── mqtt_pub_sub.feature ├── steps │ ├── clients_steps.coffee │ ├── http_steps.coffee │ ├── web_client_steps.coffee │ └── world_overrider.coffee ├── support │ ├── clients │ │ ├── http.coffee │ │ ├── http_json.coffee │ │ ├── http_txt.coffee │ │ └── mqtt.coffee │ ├── clients_helpers.coffee │ ├── reset_db_hook.coffee │ └── world.coffee └── web_interface.feature ├── npm-shrinkwrap.json ├── package.json ├── public ├── favicon.ico ├── images │ ├── grey.png │ └── schema.png ├── mu-942f19e0-e626e170-21f6d708-caa97289.txt └── stylesheets │ └── .gitkeep ├── qest.coffee ├── qest.js ├── redis.conf └── test ├── mocha.opts ├── models └── data_spec.coffee └── spec_helper.coffee /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/* 2 | *~ 3 | *.rdb 4 | public/stylesheets/*.css 5 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "node_modules/jasmine-node"] 2 | path = node_modules/jasmine-node 3 | url = git@github.com:mcollina/jasmine-node.git 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 0.8 4 | services: 5 | - redis-server 6 | -------------------------------------------------------------------------------- /Cakefile: -------------------------------------------------------------------------------- 1 | 2 | child_process = require('child_process') 3 | process = global.process 4 | path = require('path') 5 | 6 | runExternal = (command, callback) -> 7 | console.log("Running #{command}") 8 | child = child_process.spawn("/bin/sh", ["-c", command]) 9 | child.stdout.on "data", (data) -> process.stdout.write(data) 10 | child.stderr.on "data", (data) -> process.stderr.write(data) 11 | child.on('exit', callback) if callback? 12 | 13 | launchSpec = (args, callback) -> 14 | runExternal "NODE_ENV=test ./node_modules/.bin/mocha --compilers coffee:coffee-script #{args}", callback 15 | 16 | task "spec", -> 17 | launchSpec "--recursive test", (result) -> 18 | process.exit(result) 19 | 20 | task "spec:ci", -> 21 | launchSpec "--watch --recursive test" 22 | 23 | task "features", -> 24 | runExternal "NODE_ENV=test ./node_modules/.bin/cucumber.js -t ~@wip", (result) -> 25 | if result != 0 26 | console.log "FAIL: scenarios should not fail" 27 | process.exit(result) 28 | 29 | task "features:wip", -> 30 | runExternal "NODE_ENV=test ./node_modules/.bin/cucumber.js -t @wip", (result) -> 31 | if result == 0 32 | console.log "FAIL: wip scenarios should fail" 33 | process.exit(1) 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012 Matteo Collina 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # QEST 3 | 4 | [![Build 5 | Status](https://travis-ci.org/mcollina/qest.png)](https://travis-ci.org/mcollina/qest) 6 | 7 | Hello geeks! 8 | 9 | What you are seeing here is a prototype of a distributed [MQTT](http://mqtt.org) broker which is accessible through REST. 10 | That's a lot of jargon, so let me show you the whole picture! 11 | 12 | Here we are dreaming a Web of Things, where you can reach (and interact) with each of your "real" devices using the web, 13 | as it's the Way everybody interacts with a computer these days. 14 | However it's somewhat hard to build these kind of apps, so researchers have written custom protocols for communicating 15 | with the devices. 16 | The state-of-the-art seems to be [MQTT](http://mqtt.org), which is standard, free of royalties, and widespread: 17 | there are libraries for all the major platforms. 18 | 19 | QEST is a stargate between the universe of devices which speak MQTT, and the universe of apps which 20 | speak HTTP. 21 | In this way you don't have to deal any custom protocol, you just GET and PUT the topic URI, like these: 22 | 23 | $ curl -X PUT -d '{ "hello": 555 }' \ 24 | -H "Content-Type: application/json" \ 25 | http://mqtt.matteocollina.com/topics/prova 26 | $ curl http://mqtt.matteocollina.com/topics/prova 27 | { "hello": 555 } 28 | 29 | Let's build cool things with MQTT, REST and Arduino! 30 | 31 | ## Usage 32 | 33 | Install [Node.js](http://nodejs.org) version 0.8, and 34 | [Redis](http://redis.io). 35 | 36 | ``` 37 | $ git clone git@github.com:mcollina/qest.git 38 | $ cd qest 39 | $ npm install 40 | $ ./qest.js 41 | ``` 42 | 43 | ## Examples 44 | 45 | * [NetworkButton](https://github.com/mcollina/qest/wiki/Network-Button-Example) 46 | * [NetworkButtonJSON](https://gist.github.com/mcollina/5337389), same as 47 | before, but exchanging JSONs. 48 | 49 | ## Contribute 50 | 51 | * Check out the latest master to make sure the feature hasn't been 52 | implemented or the bug hasn't been fixed yet 53 | * Check out the issue tracker to make sure someone already hasn't 54 | requested it and/or contributed it 55 | * Fork the project 56 | * Start a feature/bugfix branch 57 | * Commit and push until you are happy with your contribution 58 | * Make sure to add tests for it. This is important so I don't break it 59 | in a future version unintentionally. 60 | * Please try not to mess with the Cakefile and package.json. If you 61 | want to have your own version, or is otherwise necessary, that is 62 | fine, but please isolate to its own commit so I can cherry-pick around 63 | it. 64 | 65 | ## Thanks 66 | 67 | This work would not have been possible without the support 68 | of the University of Bologna, which funded my Ph.D. program. 69 | Moreover I would like to thank my professors, Giovanni 70 | Emanuele Corazza and Alessandro Vanelli-Coralli for the support 71 | and the feedbacks. 72 | 73 | ## License 74 | 75 | Copyright (c) 2012 Matteo Collina, http://matteocollina.com 76 | 77 | Permission is hereby granted, free of charge, to any person 78 | obtaining a copy of this software and associated documentation 79 | files (the "Software"), to deal in the Software without 80 | restriction, including without limitation the rights to use, 81 | copy, modify, merge, publish, distribute, sublicense, and/or sell 82 | copies of the Software, and to permit persons to whom the 83 | Software is furnished to do so, subject to the following 84 | conditions: 85 | 86 | The above copyright notice and this permission notice shall be 87 | included in all copies or substantial portions of the Software. 88 | 89 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 90 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 91 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 92 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 93 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 94 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 95 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 96 | OTHER DEALINGS IN THE SOFTWARE. 97 | -------------------------------------------------------------------------------- /app/assets/css/lib/accordion.less: -------------------------------------------------------------------------------- 1 | // 2 | // Accordion 3 | // -------------------------------------------------- 4 | 5 | 6 | // Parent container 7 | .accordion { 8 | margin-bottom: @baseLineHeight; 9 | } 10 | 11 | // Group == heading + body 12 | .accordion-group { 13 | margin-bottom: 2px; 14 | border: 1px solid #e5e5e5; 15 | .border-radius(4px); 16 | } 17 | .accordion-heading { 18 | border-bottom: 0; 19 | } 20 | .accordion-heading .accordion-toggle { 21 | display: block; 22 | padding: 8px 15px; 23 | } 24 | 25 | // General toggle styles 26 | .accordion-toggle { 27 | cursor: pointer; 28 | } 29 | 30 | // Inner needs the styles because you can't animate properly with any styles on the element 31 | .accordion-inner { 32 | padding: 9px 15px; 33 | border-top: 1px solid #e5e5e5; 34 | } 35 | -------------------------------------------------------------------------------- /app/assets/css/lib/alerts.less: -------------------------------------------------------------------------------- 1 | // 2 | // Alerts 3 | // -------------------------------------------------- 4 | 5 | 6 | // Base styles 7 | // ------------------------- 8 | 9 | .alert { 10 | padding: 8px 35px 8px 14px; 11 | margin-bottom: @baseLineHeight; 12 | text-shadow: 0 1px 0 rgba(255,255,255,.5); 13 | background-color: @warningBackground; 14 | border: 1px solid @warningBorder; 15 | .border-radius(4px); 16 | color: @warningText; 17 | } 18 | .alert h4 { 19 | margin: 0; 20 | } 21 | 22 | // Adjust close link position 23 | .alert .close { 24 | position: relative; 25 | top: -2px; 26 | right: -21px; 27 | line-height: @baseLineHeight; 28 | } 29 | 30 | 31 | // Alternate styles 32 | // ------------------------- 33 | 34 | .alert-success { 35 | background-color: @successBackground; 36 | border-color: @successBorder; 37 | color: @successText; 38 | } 39 | .alert-danger, 40 | .alert-error { 41 | background-color: @errorBackground; 42 | border-color: @errorBorder; 43 | color: @errorText; 44 | } 45 | .alert-info { 46 | background-color: @infoBackground; 47 | border-color: @infoBorder; 48 | color: @infoText; 49 | } 50 | 51 | 52 | // Block alerts 53 | // ------------------------- 54 | 55 | .alert-block { 56 | padding-top: 14px; 57 | padding-bottom: 14px; 58 | } 59 | .alert-block > p, 60 | .alert-block > ul { 61 | margin-bottom: 0; 62 | } 63 | .alert-block p + p { 64 | margin-top: 5px; 65 | } 66 | -------------------------------------------------------------------------------- /app/assets/css/lib/bootstrap.less: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap v2.1.1 3 | * 4 | * Copyright 2012 Twitter, Inc 5 | * Licensed under the Apache License v2.0 6 | * http://www.apache.org/licenses/LICENSE-2.0 7 | * 8 | * Designed and built with all the love in the world @twitter by @mdo and @fat. 9 | */ 10 | 11 | // CSS Reset 12 | @import "reset.less"; 13 | 14 | // Core variables and mixins 15 | @import "variables.less"; // Modify this for custom colors, font-sizes, etc 16 | @import "mixins.less"; 17 | 18 | // Grid system and page structure 19 | @import "scaffolding.less"; 20 | @import "grid.less"; 21 | @import "layouts.less"; 22 | 23 | // Base CSS 24 | @import "type.less"; 25 | @import "code.less"; 26 | @import "forms.less"; 27 | @import "tables.less"; 28 | 29 | // Components: common 30 | @import "sprites.less"; 31 | @import "dropdowns.less"; 32 | @import "wells.less"; 33 | @import "component-animations.less"; 34 | @import "close.less"; 35 | 36 | // Components: Buttons & Alerts 37 | @import "buttons.less"; 38 | @import "button-groups.less"; 39 | @import "alerts.less"; // Note: alerts share common CSS with buttons and thus have styles in buttons.less 40 | 41 | // Components: Nav 42 | @import "navs.less"; 43 | @import "navbar.less"; 44 | @import "breadcrumbs.less"; 45 | @import "pagination.less"; 46 | @import "pager.less"; 47 | 48 | // Components: Popovers 49 | @import "modals.less"; 50 | @import "tooltip.less"; 51 | @import "popovers.less"; 52 | 53 | // Components: Misc 54 | @import "thumbnails.less"; 55 | @import "labels-badges.less"; 56 | @import "progress-bars.less"; 57 | @import "accordion.less"; 58 | @import "carousel.less"; 59 | @import "hero-unit.less"; 60 | 61 | // Utility classes 62 | @import "utilities.less"; // Has to be last to override when necessary 63 | -------------------------------------------------------------------------------- /app/assets/css/lib/breadcrumbs.less: -------------------------------------------------------------------------------- 1 | // 2 | // Breadcrumbs 3 | // -------------------------------------------------- 4 | 5 | 6 | .breadcrumb { 7 | padding: 8px 15px; 8 | margin: 0 0 @baseLineHeight; 9 | list-style: none; 10 | background-color: #f5f5f5; 11 | .border-radius(4px); 12 | li { 13 | display: inline-block; 14 | .ie7-inline-block(); 15 | text-shadow: 0 1px 0 @white; 16 | } 17 | .divider { 18 | padding: 0 5px; 19 | color: #ccc; 20 | } 21 | .active { 22 | color: @grayLight; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /app/assets/css/lib/button-groups.less: -------------------------------------------------------------------------------- 1 | // 2 | // Button groups 3 | // -------------------------------------------------- 4 | 5 | 6 | // Make the div behave like a button 7 | .btn-group { 8 | position: relative; 9 | font-size: 0; // remove as part 1 of font-size inline-block hack 10 | vertical-align: middle; // match .btn alignment given font-size hack above 11 | white-space: nowrap; // prevent buttons from wrapping when in tight spaces (e.g., the table on the tests page) 12 | .ie7-restore-left-whitespace(); 13 | } 14 | 15 | // Space out series of button groups 16 | .btn-group + .btn-group { 17 | margin-left: 5px; 18 | } 19 | 20 | // Optional: Group multiple button groups together for a toolbar 21 | .btn-toolbar { 22 | font-size: 0; // Hack to remove whitespace that results from using inline-block 23 | margin-top: @baseLineHeight / 2; 24 | margin-bottom: @baseLineHeight / 2; 25 | .btn-group { 26 | display: inline-block; 27 | .ie7-inline-block(); 28 | } 29 | .btn + .btn, 30 | .btn-group + .btn, 31 | .btn + .btn-group { 32 | margin-left: 5px; 33 | } 34 | } 35 | 36 | // Float them, remove border radius, then re-add to first and last elements 37 | .btn-group > .btn { 38 | position: relative; 39 | .border-radius(0); 40 | } 41 | .btn-group > .btn + .btn { 42 | margin-left: -1px; 43 | } 44 | .btn-group > .btn, 45 | .btn-group > .dropdown-menu { 46 | font-size: @baseFontSize; // redeclare as part 2 of font-size inline-block hack 47 | } 48 | 49 | // Reset fonts for other sizes 50 | .btn-group > .btn-mini { 51 | font-size: 11px; 52 | } 53 | .btn-group > .btn-small { 54 | font-size: 12px; 55 | } 56 | .btn-group > .btn-large { 57 | font-size: 16px; 58 | } 59 | 60 | // Set corners individual because sometimes a single button can be in a .btn-group and we need :first-child and :last-child to both match 61 | .btn-group > .btn:first-child { 62 | margin-left: 0; 63 | -webkit-border-top-left-radius: 4px; 64 | -moz-border-radius-topleft: 4px; 65 | border-top-left-radius: 4px; 66 | -webkit-border-bottom-left-radius: 4px; 67 | -moz-border-radius-bottomleft: 4px; 68 | border-bottom-left-radius: 4px; 69 | } 70 | // Need .dropdown-toggle since :last-child doesn't apply given a .dropdown-menu immediately after it 71 | .btn-group > .btn:last-child, 72 | .btn-group > .dropdown-toggle { 73 | -webkit-border-top-right-radius: 4px; 74 | -moz-border-radius-topright: 4px; 75 | border-top-right-radius: 4px; 76 | -webkit-border-bottom-right-radius: 4px; 77 | -moz-border-radius-bottomright: 4px; 78 | border-bottom-right-radius: 4px; 79 | } 80 | // Reset corners for large buttons 81 | .btn-group > .btn.large:first-child { 82 | margin-left: 0; 83 | -webkit-border-top-left-radius: 6px; 84 | -moz-border-radius-topleft: 6px; 85 | border-top-left-radius: 6px; 86 | -webkit-border-bottom-left-radius: 6px; 87 | -moz-border-radius-bottomleft: 6px; 88 | border-bottom-left-radius: 6px; 89 | } 90 | .btn-group > .btn.large:last-child, 91 | .btn-group > .large.dropdown-toggle { 92 | -webkit-border-top-right-radius: 6px; 93 | -moz-border-radius-topright: 6px; 94 | border-top-right-radius: 6px; 95 | -webkit-border-bottom-right-radius: 6px; 96 | -moz-border-radius-bottomright: 6px; 97 | border-bottom-right-radius: 6px; 98 | } 99 | 100 | // On hover/focus/active, bring the proper btn to front 101 | .btn-group > .btn:hover, 102 | .btn-group > .btn:focus, 103 | .btn-group > .btn:active, 104 | .btn-group > .btn.active { 105 | z-index: 2; 106 | } 107 | 108 | // On active and open, don't show outline 109 | .btn-group .dropdown-toggle:active, 110 | .btn-group.open .dropdown-toggle { 111 | outline: 0; 112 | } 113 | 114 | 115 | 116 | // Split button dropdowns 117 | // ---------------------- 118 | 119 | // Give the line between buttons some depth 120 | .btn-group > .btn + .dropdown-toggle { 121 | padding-left: 8px; 122 | padding-right: 8px; 123 | .box-shadow(inset 1px 0 0 rgba(255,255,255,.125), inset 0 1px 0 rgba(255,255,255,.2), 0 1px 2px rgba(0,0,0,.05)); 124 | *padding-top: 5px; 125 | *padding-bottom: 5px; 126 | } 127 | .btn-group > .btn-mini + .dropdown-toggle { 128 | padding-left: 5px; 129 | padding-right: 5px; 130 | *padding-top: 2px; 131 | *padding-bottom: 2px; 132 | } 133 | .btn-group > .btn-small + .dropdown-toggle { 134 | *padding-top: 5px; 135 | *padding-bottom: 4px; 136 | } 137 | .btn-group > .btn-large + .dropdown-toggle { 138 | padding-left: 12px; 139 | padding-right: 12px; 140 | *padding-top: 7px; 141 | *padding-bottom: 7px; 142 | } 143 | 144 | .btn-group.open { 145 | 146 | // The clickable button for toggling the menu 147 | // Remove the gradient and set the same inset shadow as the :active state 148 | .dropdown-toggle { 149 | background-image: none; 150 | .box-shadow(inset 0 2px 4px rgba(0,0,0,.15), 0 1px 2px rgba(0,0,0,.05)); 151 | } 152 | 153 | // Keep the hover's background when dropdown is open 154 | .btn.dropdown-toggle { 155 | background-color: @btnBackgroundHighlight; 156 | } 157 | .btn-primary.dropdown-toggle { 158 | background-color: @btnPrimaryBackgroundHighlight; 159 | } 160 | .btn-warning.dropdown-toggle { 161 | background-color: @btnWarningBackgroundHighlight; 162 | } 163 | .btn-danger.dropdown-toggle { 164 | background-color: @btnDangerBackgroundHighlight; 165 | } 166 | .btn-success.dropdown-toggle { 167 | background-color: @btnSuccessBackgroundHighlight; 168 | } 169 | .btn-info.dropdown-toggle { 170 | background-color: @btnInfoBackgroundHighlight; 171 | } 172 | .btn-inverse.dropdown-toggle { 173 | background-color: @btnInverseBackgroundHighlight; 174 | } 175 | } 176 | 177 | 178 | // Reposition the caret 179 | .btn .caret { 180 | margin-top: 8px; 181 | margin-left: 0; 182 | } 183 | // Carets in other button sizes 184 | .btn-mini .caret, 185 | .btn-small .caret, 186 | .btn-large .caret { 187 | margin-top: 6px; 188 | } 189 | .btn-large .caret { 190 | border-left-width: 5px; 191 | border-right-width: 5px; 192 | border-top-width: 5px; 193 | } 194 | // Upside down carets for .dropup 195 | .dropup .btn-large .caret { 196 | border-bottom: 5px solid @black; 197 | border-top: 0; 198 | } 199 | 200 | 201 | 202 | // Account for other colors 203 | .btn-primary, 204 | .btn-warning, 205 | .btn-danger, 206 | .btn-info, 207 | .btn-success, 208 | .btn-inverse { 209 | .caret { 210 | border-top-color: @white; 211 | border-bottom-color: @white; 212 | } 213 | } 214 | 215 | 216 | 217 | // Vertical button groups 218 | // ---------------------- 219 | 220 | .btn-group-vertical { 221 | display: inline-block; // makes buttons only take up the width they need 222 | .ie7-inline-block(); 223 | } 224 | .btn-group-vertical .btn { 225 | display: block; 226 | float: none; 227 | width: 100%; 228 | .border-radius(0); 229 | } 230 | .btn-group-vertical .btn + .btn { 231 | margin-left: 0; 232 | margin-top: -1px; 233 | } 234 | .btn-group-vertical .btn:first-child { 235 | .border-radius(4px 4px 0 0); 236 | } 237 | .btn-group-vertical .btn:last-child { 238 | .border-radius(0 0 4px 4px); 239 | } 240 | .btn-group-vertical .btn-large:first-child { 241 | .border-radius(6px 6px 0 0); 242 | } 243 | .btn-group-vertical .btn-large:last-child { 244 | .border-radius(0 0 6px 6px); 245 | } 246 | -------------------------------------------------------------------------------- /app/assets/css/lib/buttons.less: -------------------------------------------------------------------------------- 1 | // 2 | // Buttons 3 | // -------------------------------------------------- 4 | 5 | 6 | // Base styles 7 | // -------------------------------------------------- 8 | 9 | // Core 10 | .btn { 11 | display: inline-block; 12 | .ie7-inline-block(); 13 | padding: 4px 14px; 14 | margin-bottom: 0; // For input.btn 15 | font-size: @baseFontSize; 16 | line-height: @baseLineHeight; 17 | *line-height: @baseLineHeight; 18 | text-align: center; 19 | vertical-align: middle; 20 | cursor: pointer; 21 | .buttonBackground(@btnBackground, @btnBackgroundHighlight, @grayDark, 0 1px 1px rgba(255,255,255,.75)); 22 | border: 1px solid @btnBorder; 23 | *border: 0; // Remove the border to prevent IE7's black border on input:focus 24 | border-bottom-color: darken(@btnBorder, 10%); 25 | .border-radius(4px); 26 | .ie7-restore-left-whitespace(); // Give IE7 some love 27 | .box-shadow(inset 0 1px 0 rgba(255,255,255,.2), 0 1px 2px rgba(0,0,0,.05)); 28 | 29 | // Hover state 30 | &:hover { 31 | color: @grayDark; 32 | text-decoration: none; 33 | background-color: darken(@white, 10%); 34 | *background-color: darken(@white, 15%); /* Buttons in IE7 don't get borders, so darken on hover */ 35 | background-position: 0 -15px; 36 | 37 | // transition is only when going to hover, otherwise the background 38 | // behind the gradient (there for IE<=9 fallback) gets mismatched 39 | .transition(background-position .1s linear); 40 | } 41 | 42 | // Focus state for keyboard and accessibility 43 | &:focus { 44 | .tab-focus(); 45 | } 46 | 47 | // Active state 48 | &.active, 49 | &:active { 50 | background-color: darken(@white, 10%); 51 | background-color: darken(@white, 15%) e("\9"); 52 | background-image: none; 53 | outline: 0; 54 | .box-shadow(inset 0 2px 4px rgba(0,0,0,.15), 0 1px 2px rgba(0,0,0,.05)); 55 | } 56 | 57 | // Disabled state 58 | &.disabled, 59 | &[disabled] { 60 | cursor: default; 61 | background-color: darken(@white, 10%); 62 | background-image: none; 63 | .opacity(65); 64 | .box-shadow(none); 65 | } 66 | 67 | } 68 | 69 | 70 | 71 | // Button Sizes 72 | // -------------------------------------------------- 73 | 74 | // Large 75 | .btn-large { 76 | padding: 9px 14px; 77 | font-size: @baseFontSize + 2px; 78 | line-height: normal; 79 | .border-radius(5px); 80 | } 81 | .btn-large [class^="icon-"] { 82 | margin-top: 2px; 83 | } 84 | 85 | // Small 86 | .btn-small { 87 | padding: 3px 9px; 88 | font-size: @baseFontSize - 2px; 89 | line-height: @baseLineHeight - 2px; 90 | } 91 | .btn-small [class^="icon-"] { 92 | margin-top: 0; 93 | } 94 | 95 | // Mini 96 | .btn-mini { 97 | padding: 2px 6px; 98 | font-size: @baseFontSize - 3px; 99 | line-height: @baseLineHeight - 3px; 100 | } 101 | 102 | 103 | // Block button 104 | // ------------------------- 105 | 106 | .btn-block { 107 | display: block; 108 | width: 100%; 109 | padding-left: 0; 110 | padding-right: 0; 111 | .box-sizing(border-box); 112 | } 113 | 114 | // Vertically space out multiple block buttons 115 | .btn-block + .btn-block { 116 | margin-top: 5px; 117 | } 118 | 119 | // Specificity overrides 120 | input[type="submit"], 121 | input[type="reset"], 122 | input[type="button"] { 123 | &.btn-block { 124 | width: 100%; 125 | } 126 | } 127 | 128 | 129 | 130 | // Alternate buttons 131 | // -------------------------------------------------- 132 | 133 | // Provide *some* extra contrast for those who can get it 134 | .btn-primary.active, 135 | .btn-warning.active, 136 | .btn-danger.active, 137 | .btn-success.active, 138 | .btn-info.active, 139 | .btn-inverse.active { 140 | color: rgba(255,255,255,.75); 141 | } 142 | 143 | // Set the backgrounds 144 | // ------------------------- 145 | .btn { 146 | // reset here as of 2.0.3 due to Recess property order 147 | border-color: #c5c5c5; 148 | border-color: rgba(0,0,0,.15) rgba(0,0,0,.15) rgba(0,0,0,.25); 149 | } 150 | .btn-primary { 151 | .buttonBackground(@btnPrimaryBackground, @btnPrimaryBackgroundHighlight); 152 | } 153 | // Warning appears are orange 154 | .btn-warning { 155 | .buttonBackground(@btnWarningBackground, @btnWarningBackgroundHighlight); 156 | } 157 | // Danger and error appear as red 158 | .btn-danger { 159 | .buttonBackground(@btnDangerBackground, @btnDangerBackgroundHighlight); 160 | } 161 | // Success appears as green 162 | .btn-success { 163 | .buttonBackground(@btnSuccessBackground, @btnSuccessBackgroundHighlight); 164 | } 165 | // Info appears as a neutral blue 166 | .btn-info { 167 | .buttonBackground(@btnInfoBackground, @btnInfoBackgroundHighlight); 168 | } 169 | // Inverse appears as dark gray 170 | .btn-inverse { 171 | .buttonBackground(@btnInverseBackground, @btnInverseBackgroundHighlight); 172 | } 173 | 174 | 175 | // Cross-browser Jank 176 | // -------------------------------------------------- 177 | 178 | button.btn, 179 | input[type="submit"].btn { 180 | 181 | // Firefox 3.6 only I believe 182 | &::-moz-focus-inner { 183 | padding: 0; 184 | border: 0; 185 | } 186 | 187 | // IE7 has some default padding on button controls 188 | *padding-top: 3px; 189 | *padding-bottom: 3px; 190 | 191 | &.btn-large { 192 | *padding-top: 7px; 193 | *padding-bottom: 7px; 194 | } 195 | &.btn-small { 196 | *padding-top: 3px; 197 | *padding-bottom: 3px; 198 | } 199 | &.btn-mini { 200 | *padding-top: 1px; 201 | *padding-bottom: 1px; 202 | } 203 | } 204 | 205 | 206 | // Link buttons 207 | // -------------------------------------------------- 208 | 209 | // Make a button look and behave like a link 210 | .btn-link, 211 | .btn-link:active, 212 | .btn-link[disabled] { 213 | background-color: transparent; 214 | background-image: none; 215 | .box-shadow(none); 216 | } 217 | .btn-link { 218 | border-color: transparent; 219 | cursor: pointer; 220 | color: @linkColor; 221 | .border-radius(0); 222 | } 223 | .btn-link:hover { 224 | color: @linkColorHover; 225 | text-decoration: underline; 226 | background-color: transparent; 227 | } 228 | .btn-link[disabled]:hover { 229 | color: @grayDark; 230 | text-decoration: none; 231 | } 232 | -------------------------------------------------------------------------------- /app/assets/css/lib/carousel.less: -------------------------------------------------------------------------------- 1 | // 2 | // Carousel 3 | // -------------------------------------------------- 4 | 5 | 6 | .carousel { 7 | position: relative; 8 | margin-bottom: @baseLineHeight; 9 | line-height: 1; 10 | } 11 | 12 | .carousel-inner { 13 | overflow: hidden; 14 | width: 100%; 15 | position: relative; 16 | } 17 | 18 | .carousel { 19 | 20 | .item { 21 | display: none; 22 | position: relative; 23 | .transition(.6s ease-in-out left); 24 | } 25 | 26 | // Account for jankitude on images 27 | .item > img { 28 | display: block; 29 | line-height: 1; 30 | } 31 | 32 | .active, 33 | .next, 34 | .prev { display: block; } 35 | 36 | .active { 37 | left: 0; 38 | } 39 | 40 | .next, 41 | .prev { 42 | position: absolute; 43 | top: 0; 44 | width: 100%; 45 | } 46 | 47 | .next { 48 | left: 100%; 49 | } 50 | .prev { 51 | left: -100%; 52 | } 53 | .next.left, 54 | .prev.right { 55 | left: 0; 56 | } 57 | 58 | .active.left { 59 | left: -100%; 60 | } 61 | .active.right { 62 | left: 100%; 63 | } 64 | 65 | } 66 | 67 | // Left/right controls for nav 68 | // --------------------------- 69 | 70 | .carousel-control { 71 | position: absolute; 72 | top: 40%; 73 | left: 15px; 74 | width: 40px; 75 | height: 40px; 76 | margin-top: -20px; 77 | font-size: 60px; 78 | font-weight: 100; 79 | line-height: 30px; 80 | color: @white; 81 | text-align: center; 82 | background: @grayDarker; 83 | border: 3px solid @white; 84 | .border-radius(23px); 85 | .opacity(50); 86 | 87 | // we can't have this transition here 88 | // because webkit cancels the carousel 89 | // animation if you trip this while 90 | // in the middle of another animation 91 | // ;_; 92 | // .transition(opacity .2s linear); 93 | 94 | // Reposition the right one 95 | &.right { 96 | left: auto; 97 | right: 15px; 98 | } 99 | 100 | // Hover state 101 | &:hover { 102 | color: @white; 103 | text-decoration: none; 104 | .opacity(90); 105 | } 106 | } 107 | 108 | 109 | // Caption for text below images 110 | // ----------------------------- 111 | 112 | .carousel-caption { 113 | position: absolute; 114 | left: 0; 115 | right: 0; 116 | bottom: 0; 117 | padding: 15px; 118 | background: @grayDark; 119 | background: rgba(0,0,0,.75); 120 | } 121 | .carousel-caption h4, 122 | .carousel-caption p { 123 | color: @white; 124 | line-height: @baseLineHeight; 125 | } 126 | .carousel-caption h4 { 127 | margin: 0 0 5px; 128 | } 129 | .carousel-caption p { 130 | margin-bottom: 0; 131 | } 132 | -------------------------------------------------------------------------------- /app/assets/css/lib/close.less: -------------------------------------------------------------------------------- 1 | // 2 | // Close icons 3 | // -------------------------------------------------- 4 | 5 | 6 | .close { 7 | float: right; 8 | font-size: 20px; 9 | font-weight: bold; 10 | line-height: @baseLineHeight; 11 | color: @black; 12 | text-shadow: 0 1px 0 rgba(255,255,255,1); 13 | .opacity(20); 14 | &:hover { 15 | color: @black; 16 | text-decoration: none; 17 | cursor: pointer; 18 | .opacity(40); 19 | } 20 | } 21 | 22 | // Additional properties for button version 23 | // iOS requires the button element instead of an anchor tag. 24 | // If you want the anchor version, it requires `href="#"`. 25 | button.close { 26 | padding: 0; 27 | cursor: pointer; 28 | background: transparent; 29 | border: 0; 30 | -webkit-appearance: none; 31 | } -------------------------------------------------------------------------------- /app/assets/css/lib/code.less: -------------------------------------------------------------------------------- 1 | // 2 | // Code (inline and blocK) 3 | // -------------------------------------------------- 4 | 5 | 6 | // Inline and block code styles 7 | code, 8 | pre { 9 | padding: 0 3px 2px; 10 | #font > #family > .monospace; 11 | font-size: @baseFontSize - 2; 12 | color: @grayDark; 13 | .border-radius(3px); 14 | } 15 | 16 | // Inline code 17 | code { 18 | padding: 2px 4px; 19 | color: #d14; 20 | background-color: #f7f7f9; 21 | border: 1px solid #e1e1e8; 22 | } 23 | 24 | // Blocks of code 25 | pre { 26 | display: block; 27 | padding: (@baseLineHeight - 1) / 2; 28 | margin: 0 0 @baseLineHeight / 2; 29 | font-size: @baseFontSize - 1; // 14px to 13px 30 | line-height: @baseLineHeight; 31 | word-break: break-all; 32 | word-wrap: break-word; 33 | white-space: pre; 34 | white-space: pre-wrap; 35 | background-color: #f5f5f5; 36 | border: 1px solid #ccc; // fallback for IE7-8 37 | border: 1px solid rgba(0,0,0,.15); 38 | .border-radius(4px); 39 | 40 | // Make prettyprint styles more spaced out for readability 41 | &.prettyprint { 42 | margin-bottom: @baseLineHeight; 43 | } 44 | 45 | // Account for some code outputs that place code tags in pre tags 46 | code { 47 | padding: 0; 48 | color: inherit; 49 | background-color: transparent; 50 | border: 0; 51 | } 52 | } 53 | 54 | // Enable scrollable blocks of code 55 | .pre-scrollable { 56 | max-height: 340px; 57 | overflow-y: scroll; 58 | } -------------------------------------------------------------------------------- /app/assets/css/lib/component-animations.less: -------------------------------------------------------------------------------- 1 | // 2 | // Component animations 3 | // -------------------------------------------------- 4 | 5 | 6 | .fade { 7 | opacity: 0; 8 | .transition(opacity .15s linear); 9 | &.in { 10 | opacity: 1; 11 | } 12 | } 13 | 14 | .collapse { 15 | position: relative; 16 | height: 0; 17 | overflow: hidden; 18 | .transition(height .35s ease); 19 | &.in { 20 | height: auto; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /app/assets/css/lib/dropdowns.less: -------------------------------------------------------------------------------- 1 | // 2 | // Dropdown menus 3 | // -------------------------------------------------- 4 | 5 | 6 | // Use the .menu class on any
  • element within the topbar or ul.tabs and you'll get some superfancy dropdowns 7 | .dropup, 8 | .dropdown { 9 | position: relative; 10 | } 11 | .dropdown-toggle { 12 | // The caret makes the toggle a bit too tall in IE7 13 | *margin-bottom: -3px; 14 | } 15 | .dropdown-toggle:active, 16 | .open .dropdown-toggle { 17 | outline: 0; 18 | } 19 | 20 | // Dropdown arrow/caret 21 | // -------------------- 22 | .caret { 23 | display: inline-block; 24 | width: 0; 25 | height: 0; 26 | vertical-align: top; 27 | border-top: 4px solid @black; 28 | border-right: 4px solid transparent; 29 | border-left: 4px solid transparent; 30 | content: ""; 31 | } 32 | 33 | // Place the caret 34 | .dropdown .caret { 35 | margin-top: 8px; 36 | margin-left: 2px; 37 | } 38 | 39 | // The dropdown menu (ul) 40 | // ---------------------- 41 | .dropdown-menu { 42 | position: absolute; 43 | top: 100%; 44 | left: 0; 45 | z-index: @zindexDropdown; 46 | display: none; // none by default, but block on "open" of the menu 47 | float: left; 48 | min-width: 160px; 49 | padding: 5px 0; 50 | margin: 2px 0 0; // override default ul 51 | list-style: none; 52 | background-color: @dropdownBackground; 53 | border: 1px solid #ccc; // Fallback for IE7-8 54 | border: 1px solid @dropdownBorder; 55 | *border-right-width: 2px; 56 | *border-bottom-width: 2px; 57 | .border-radius(6px); 58 | .box-shadow(0 5px 10px rgba(0,0,0,.2)); 59 | -webkit-background-clip: padding-box; 60 | -moz-background-clip: padding; 61 | background-clip: padding-box; 62 | 63 | // Aligns the dropdown menu to right 64 | &.pull-right { 65 | right: 0; 66 | left: auto; 67 | } 68 | 69 | // Dividers (basically an hr) within the dropdown 70 | .divider { 71 | .nav-divider(@dropdownDividerTop, @dropdownDividerBottom); 72 | } 73 | 74 | // Links within the dropdown menu 75 | a { 76 | display: block; 77 | padding: 3px 20px; 78 | clear: both; 79 | font-weight: normal; 80 | line-height: @baseLineHeight; 81 | color: @dropdownLinkColor; 82 | white-space: nowrap; 83 | } 84 | } 85 | 86 | // Hover state 87 | // ----------- 88 | .dropdown-menu li > a:hover, 89 | .dropdown-menu li > a:focus, 90 | .dropdown-submenu:hover > a { 91 | text-decoration: none; 92 | color: @dropdownLinkColorHover; 93 | background-color: @dropdownLinkBackgroundHover; 94 | #gradient > .vertical(@dropdownLinkBackgroundHover, darken(@dropdownLinkBackgroundHover, 5%)); 95 | } 96 | 97 | // Active state 98 | // ------------ 99 | .dropdown-menu .active > a, 100 | .dropdown-menu .active > a:hover { 101 | color: @dropdownLinkColorHover; 102 | text-decoration: none; 103 | outline: 0; 104 | background-color: @dropdownLinkBackgroundActive; 105 | #gradient > .vertical(@dropdownLinkBackgroundActive, darken(@dropdownLinkBackgroundActive, 5%)); 106 | } 107 | 108 | // Disabled state 109 | // -------------- 110 | // Gray out text and ensure the hover state remains gray 111 | .dropdown-menu .disabled > a, 112 | .dropdown-menu .disabled > a:hover { 113 | color: @grayLight; 114 | } 115 | // Nuke hover effects 116 | .dropdown-menu .disabled > a:hover { 117 | text-decoration: none; 118 | background-color: transparent; 119 | cursor: default; 120 | } 121 | 122 | // Open state for the dropdown 123 | // --------------------------- 124 | .open { 125 | // IE7's z-index only goes to the nearest positioned ancestor, which would 126 | // make the menu appear below buttons that appeared later on the page 127 | *z-index: @zindexDropdown; 128 | 129 | & > .dropdown-menu { 130 | display: block; 131 | } 132 | } 133 | 134 | // Right aligned dropdowns 135 | // --------------------------- 136 | .pull-right > .dropdown-menu { 137 | right: 0; 138 | left: auto; 139 | } 140 | 141 | // Allow for dropdowns to go bottom up (aka, dropup-menu) 142 | // ------------------------------------------------------ 143 | // Just add .dropup after the standard .dropdown class and you're set, bro. 144 | // TODO: abstract this so that the navbar fixed styles are not placed here? 145 | .dropup, 146 | .navbar-fixed-bottom .dropdown { 147 | // Reverse the caret 148 | .caret { 149 | border-top: 0; 150 | border-bottom: 4px solid @black; 151 | content: ""; 152 | } 153 | // Different positioning for bottom up menu 154 | .dropdown-menu { 155 | top: auto; 156 | bottom: 100%; 157 | margin-bottom: 1px; 158 | } 159 | } 160 | 161 | // Sub menus 162 | // --------------------------- 163 | .dropdown-submenu { 164 | position: relative; 165 | } 166 | .dropdown-submenu > .dropdown-menu { 167 | top: 0; 168 | left: 100%; 169 | margin-top: -6px; 170 | margin-left: -1px; 171 | -webkit-border-radius: 0 6px 6px 6px; 172 | -moz-border-radius: 0 6px 6px 6px; 173 | border-radius: 0 6px 6px 6px; 174 | } 175 | .dropdown-submenu:hover > .dropdown-menu { 176 | display: block; 177 | } 178 | 179 | .dropdown-submenu > a:after { 180 | display: block; 181 | content: " "; 182 | float: right; 183 | width: 0; 184 | height: 0; 185 | border-color: transparent; 186 | border-style: solid; 187 | border-width: 5px 0 5px 5px; 188 | border-left-color: darken(@dropdownBackground, 20%); 189 | margin-top: 5px; 190 | margin-right: -10px; 191 | } 192 | .dropdown-submenu:hover > a:after { 193 | border-left-color: @dropdownLinkColorHover; 194 | } 195 | 196 | 197 | // Tweak nav headers 198 | // ----------------- 199 | // Increase padding from 15px to 20px on sides 200 | .dropdown .dropdown-menu .nav-header { 201 | padding-left: 20px; 202 | padding-right: 20px; 203 | } 204 | 205 | // Typeahead 206 | // --------- 207 | .typeahead { 208 | margin-top: 2px; // give it some space to breathe 209 | .border-radius(4px); 210 | } 211 | -------------------------------------------------------------------------------- /app/assets/css/lib/grid.less: -------------------------------------------------------------------------------- 1 | // 2 | // Grid system 3 | // -------------------------------------------------- 4 | 5 | 6 | // Fixed (940px) 7 | #grid > .core(@gridColumnWidth, @gridGutterWidth); 8 | 9 | // Fluid (940px) 10 | #grid > .fluid(@fluidGridColumnWidth, @fluidGridGutterWidth); 11 | 12 | // Reset utility classes due to specificity 13 | [class*="span"].hide, 14 | .row-fluid [class*="span"].hide { 15 | display: none; 16 | } 17 | 18 | [class*="span"].pull-right, 19 | .row-fluid [class*="span"].pull-right { 20 | float: right; 21 | } 22 | -------------------------------------------------------------------------------- /app/assets/css/lib/hero-unit.less: -------------------------------------------------------------------------------- 1 | // 2 | // Hero unit 3 | // -------------------------------------------------- 4 | 5 | 6 | .hero-unit { 7 | padding: 60px; 8 | margin-bottom: 30px; 9 | background-color: @heroUnitBackground; 10 | .border-radius(6px); 11 | h1 { 12 | margin-bottom: 0; 13 | font-size: 60px; 14 | line-height: 1; 15 | color: @heroUnitHeadingColor; 16 | letter-spacing: -1px; 17 | } 18 | p { 19 | font-size: 18px; 20 | font-weight: 200; 21 | line-height: @baseLineHeight * 1.5; 22 | color: @heroUnitLeadColor; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /app/assets/css/lib/labels-badges.less: -------------------------------------------------------------------------------- 1 | // 2 | // Labels and badges 3 | // -------------------------------------------------- 4 | 5 | 6 | // Base classes 7 | .label, 8 | .badge { 9 | font-size: @baseFontSize * .846; 10 | font-weight: bold; 11 | line-height: 14px; // ensure proper line-height if floated 12 | color: @white; 13 | vertical-align: baseline; 14 | white-space: nowrap; 15 | text-shadow: 0 -1px 0 rgba(0,0,0,.25); 16 | background-color: @grayLight; 17 | } 18 | // Set unique padding and border-radii 19 | .label { 20 | padding: 1px 4px 2px; 21 | .border-radius(3px); 22 | } 23 | .badge { 24 | padding: 1px 9px 2px; 25 | .border-radius(9px); 26 | } 27 | 28 | // Hover state, but only for links 29 | a { 30 | &.label:hover, 31 | &.badge:hover { 32 | color: @white; 33 | text-decoration: none; 34 | cursor: pointer; 35 | } 36 | } 37 | 38 | // Colors 39 | // Only give background-color difference to links (and to simplify, we don't qualifty with `a` but [href] attribute) 40 | .label, 41 | .badge { 42 | // Important (red) 43 | &-important { background-color: @errorText; } 44 | &-important[href] { background-color: darken(@errorText, 10%); } 45 | // Warnings (orange) 46 | &-warning { background-color: @orange; } 47 | &-warning[href] { background-color: darken(@orange, 10%); } 48 | // Success (green) 49 | &-success { background-color: @successText; } 50 | &-success[href] { background-color: darken(@successText, 10%); } 51 | // Info (turquoise) 52 | &-info { background-color: @infoText; } 53 | &-info[href] { background-color: darken(@infoText, 10%); } 54 | // Inverse (black) 55 | &-inverse { background-color: @grayDark; } 56 | &-inverse[href] { background-color: darken(@grayDark, 10%); } 57 | } 58 | 59 | // Quick fix for labels/badges in buttons 60 | .btn { 61 | .label, 62 | .badge { 63 | position: relative; 64 | top: -1px; 65 | } 66 | } 67 | .btn-mini { 68 | .label, 69 | .badge { 70 | top: 0; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /app/assets/css/lib/layouts.less: -------------------------------------------------------------------------------- 1 | // 2 | // Layouts 3 | // -------------------------------------------------- 4 | 5 | 6 | // Container (centered, fixed-width layouts) 7 | .container { 8 | .container-fixed(); 9 | } 10 | 11 | // Fluid layouts (left aligned, with sidebar, min- & max-width content) 12 | .container-fluid { 13 | padding-right: @gridGutterWidth; 14 | padding-left: @gridGutterWidth; 15 | .clearfix(); 16 | } -------------------------------------------------------------------------------- /app/assets/css/lib/modals.less: -------------------------------------------------------------------------------- 1 | // 2 | // Modals 3 | // -------------------------------------------------- 4 | 5 | 6 | // Recalculate z-index where appropriate, 7 | // but only apply to elements within modal 8 | .modal-open .modal { 9 | .dropdown-menu { z-index: @zindexDropdown + @zindexModal; } 10 | .dropdown.open { *z-index: @zindexDropdown + @zindexModal; } 11 | .popover { z-index: @zindexPopover + @zindexModal; } 12 | .tooltip { z-index: @zindexTooltip + @zindexModal; } 13 | } 14 | 15 | // Background 16 | .modal-backdrop { 17 | position: fixed; 18 | top: 0; 19 | right: 0; 20 | bottom: 0; 21 | left: 0; 22 | z-index: @zindexModalBackdrop; 23 | background-color: @black; 24 | // Fade for backdrop 25 | &.fade { opacity: 0; } 26 | } 27 | 28 | .modal-backdrop, 29 | .modal-backdrop.fade.in { 30 | .opacity(80); 31 | } 32 | 33 | // Base modal 34 | .modal { 35 | position: fixed; 36 | top: 50%; 37 | left: 50%; 38 | z-index: @zindexModal; 39 | overflow: auto; 40 | width: 560px; 41 | margin: -250px 0 0 -280px; 42 | background-color: @white; 43 | border: 1px solid #999; 44 | border: 1px solid rgba(0,0,0,.3); 45 | *border: 1px solid #999; /* IE6-7 */ 46 | .border-radius(6px); 47 | .box-shadow(0 3px 7px rgba(0,0,0,0.3)); 48 | .background-clip(padding-box); 49 | &.fade { 50 | .transition(e('opacity .3s linear, top .3s ease-out')); 51 | top: -25%; 52 | } 53 | &.fade.in { top: 50%; } 54 | } 55 | .modal-header { 56 | padding: 9px 15px; 57 | border-bottom: 1px solid #eee; 58 | // Close icon 59 | .close { margin-top: 2px; } 60 | // Heading 61 | h3 { 62 | margin: 0; 63 | line-height: 30px; 64 | } 65 | } 66 | 67 | // Body (where all modal content resides) 68 | .modal-body { 69 | overflow-y: auto; 70 | max-height: 400px; 71 | padding: 15px; 72 | } 73 | // Remove bottom margin if need be 74 | .modal-form { 75 | margin-bottom: 0; 76 | } 77 | 78 | // Footer (for actions) 79 | .modal-footer { 80 | padding: 14px 15px 15px; 81 | margin-bottom: 0; 82 | text-align: right; // right align buttons 83 | background-color: #f5f5f5; 84 | border-top: 1px solid #ddd; 85 | .border-radius(0 0 6px 6px); 86 | .box-shadow(inset 0 1px 0 @white); 87 | .clearfix(); // clear it in case folks use .pull-* classes on buttons 88 | 89 | // Properly space out buttons 90 | .btn + .btn { 91 | margin-left: 5px; 92 | margin-bottom: 0; // account for input[type="submit"] which gets the bottom margin like all other inputs 93 | } 94 | // but override that for button groups 95 | .btn-group .btn + .btn { 96 | margin-left: -1px; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /app/assets/css/lib/pager.less: -------------------------------------------------------------------------------- 1 | // 2 | // Pager pagination 3 | // -------------------------------------------------- 4 | 5 | 6 | .pager { 7 | margin: @baseLineHeight 0; 8 | list-style: none; 9 | text-align: center; 10 | .clearfix(); 11 | } 12 | .pager li { 13 | display: inline; 14 | } 15 | .pager a, 16 | .pager span { 17 | display: inline-block; 18 | padding: 5px 14px; 19 | background-color: #fff; 20 | border: 1px solid #ddd; 21 | .border-radius(15px); 22 | } 23 | .pager a:hover { 24 | text-decoration: none; 25 | background-color: #f5f5f5; 26 | } 27 | .pager .next a, 28 | .pager .next span { 29 | float: right; 30 | } 31 | .pager .previous a { 32 | float: left; 33 | } 34 | .pager .disabled a, 35 | .pager .disabled a:hover, 36 | .pager .disabled span { 37 | color: @grayLight; 38 | background-color: #fff; 39 | cursor: default; 40 | } -------------------------------------------------------------------------------- /app/assets/css/lib/pagination.less: -------------------------------------------------------------------------------- 1 | // 2 | // Pagination (multiple pages) 3 | // -------------------------------------------------- 4 | 5 | 6 | .pagination { 7 | height: @baseLineHeight * 2; 8 | margin: @baseLineHeight 0; 9 | } 10 | .pagination ul { 11 | display: inline-block; 12 | .ie7-inline-block(); 13 | margin-left: 0; 14 | margin-bottom: 0; 15 | .border-radius(3px); 16 | .box-shadow(0 1px 2px rgba(0,0,0,.05)); 17 | } 18 | .pagination ul > li { 19 | display: inline; 20 | } 21 | .pagination ul > li > a, 22 | .pagination ul > li > span { 23 | float: left; 24 | padding: 0 14px; 25 | line-height: (@baseLineHeight * 2) - 2; 26 | text-decoration: none; 27 | background-color: @paginationBackground; 28 | border: 1px solid @paginationBorder; 29 | border-left-width: 0; 30 | } 31 | .pagination ul > li > a:hover, 32 | .pagination ul > .active > a, 33 | .pagination ul > .active > span { 34 | background-color: #f5f5f5; 35 | } 36 | .pagination ul > .active > a, 37 | .pagination ul > .active > span { 38 | color: @grayLight; 39 | cursor: default; 40 | } 41 | .pagination ul > .disabled > span, 42 | .pagination ul > .disabled > a, 43 | .pagination ul > .disabled > a:hover { 44 | color: @grayLight; 45 | background-color: transparent; 46 | cursor: default; 47 | } 48 | .pagination ul > li:first-child > a, 49 | .pagination ul > li:first-child > span { 50 | border-left-width: 1px; 51 | .border-radius(3px 0 0 3px); 52 | } 53 | .pagination ul > li:last-child > a, 54 | .pagination ul > li:last-child > span { 55 | .border-radius(0 3px 3px 0); 56 | } 57 | 58 | // Centered 59 | .pagination-centered { 60 | text-align: center; 61 | } 62 | .pagination-right { 63 | text-align: right; 64 | } 65 | -------------------------------------------------------------------------------- /app/assets/css/lib/popovers.less: -------------------------------------------------------------------------------- 1 | // 2 | // Popovers 3 | // -------------------------------------------------- 4 | 5 | 6 | .popover { 7 | position: absolute; 8 | top: 0; 9 | left: 0; 10 | z-index: @zindexPopover; 11 | display: none; 12 | width: 236px; 13 | padding: 1px; 14 | background-color: @popoverBackground; 15 | -webkit-background-clip: padding-box; 16 | -moz-background-clip: padding; 17 | background-clip: padding-box; 18 | border: 1px solid #ccc; 19 | border: 1px solid rgba(0,0,0,.2); 20 | .border-radius(6px); 21 | .box-shadow(0 5px 10px rgba(0,0,0,.2)); 22 | 23 | // Offset the popover to account for the popover arrow 24 | &.top { margin-bottom: 10px; } 25 | &.right { margin-left: 10px; } 26 | &.bottom { margin-top: 10px; } 27 | &.left { margin-right: 10px; } 28 | 29 | } 30 | 31 | .popover-title { 32 | margin: 0; // reset heading margin 33 | padding: 8px 14px; 34 | font-size: 14px; 35 | font-weight: normal; 36 | line-height: 18px; 37 | background-color: @popoverTitleBackground; 38 | border-bottom: 1px solid darken(@popoverTitleBackground, 5%); 39 | .border-radius(5px 5px 0 0); 40 | } 41 | 42 | .popover-content { 43 | padding: 9px 14px; 44 | p, ul, ol { 45 | margin-bottom: 0; 46 | } 47 | } 48 | 49 | // Arrows 50 | .popover .arrow, 51 | .popover .arrow:after { 52 | position: absolute; 53 | display: inline-block; 54 | width: 0; 55 | height: 0; 56 | border-color: transparent; 57 | border-style: solid; 58 | } 59 | .popover .arrow:after { 60 | content: ""; 61 | z-index: -1; 62 | } 63 | 64 | .popover { 65 | &.top .arrow { 66 | bottom: -@popoverArrowWidth; 67 | left: 50%; 68 | margin-left: -@popoverArrowWidth; 69 | border-width: @popoverArrowWidth @popoverArrowWidth 0; 70 | border-top-color: @popoverArrowColor; 71 | &:after { 72 | border-width: @popoverArrowOuterWidth @popoverArrowOuterWidth 0; 73 | border-top-color: @popoverArrowOuterColor; 74 | bottom: -1px; 75 | left: -@popoverArrowOuterWidth; 76 | } 77 | } 78 | &.right .arrow { 79 | top: 50%; 80 | left: -@popoverArrowWidth; 81 | margin-top: -@popoverArrowWidth; 82 | border-width: @popoverArrowWidth @popoverArrowWidth @popoverArrowWidth 0; 83 | border-right-color: @popoverArrowColor; 84 | &:after { 85 | border-width: @popoverArrowOuterWidth @popoverArrowOuterWidth @popoverArrowOuterWidth 0; 86 | border-right-color: @popoverArrowOuterColor; 87 | bottom: -@popoverArrowOuterWidth; 88 | left: -1px; 89 | } 90 | } 91 | &.bottom .arrow { 92 | top: -@popoverArrowWidth; 93 | left: 50%; 94 | margin-left: -@popoverArrowWidth; 95 | border-width: 0 @popoverArrowWidth @popoverArrowWidth; 96 | border-bottom-color: @popoverArrowColor; 97 | &:after { 98 | border-width: 0 @popoverArrowOuterWidth @popoverArrowOuterWidth; 99 | border-bottom-color: @popoverArrowOuterColor; 100 | top: -1px; 101 | left: -@popoverArrowOuterWidth; 102 | } 103 | } 104 | &.left .arrow { 105 | top: 50%; 106 | right: -@popoverArrowWidth; 107 | margin-top: -@popoverArrowWidth; 108 | border-width: @popoverArrowWidth 0 @popoverArrowWidth @popoverArrowWidth; 109 | border-left-color: @popoverArrowColor; 110 | &:after { 111 | border-width: @popoverArrowOuterWidth 0 @popoverArrowOuterWidth @popoverArrowOuterWidth; 112 | border-left-color: @popoverArrowOuterColor; 113 | bottom: -@popoverArrowOuterWidth; 114 | right: -1px; 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /app/assets/css/lib/progress-bars.less: -------------------------------------------------------------------------------- 1 | // 2 | // Progress bars 3 | // -------------------------------------------------- 4 | 5 | 6 | // ANIMATIONS 7 | // ---------- 8 | 9 | // Webkit 10 | @-webkit-keyframes progress-bar-stripes { 11 | from { background-position: 40px 0; } 12 | to { background-position: 0 0; } 13 | } 14 | 15 | // Firefox 16 | @-moz-keyframes progress-bar-stripes { 17 | from { background-position: 40px 0; } 18 | to { background-position: 0 0; } 19 | } 20 | 21 | // IE9 22 | @-ms-keyframes progress-bar-stripes { 23 | from { background-position: 40px 0; } 24 | to { background-position: 0 0; } 25 | } 26 | 27 | // Opera 28 | @-o-keyframes progress-bar-stripes { 29 | from { background-position: 0 0; } 30 | to { background-position: 40px 0; } 31 | } 32 | 33 | // Spec 34 | @keyframes progress-bar-stripes { 35 | from { background-position: 40px 0; } 36 | to { background-position: 0 0; } 37 | } 38 | 39 | 40 | 41 | // THE BARS 42 | // -------- 43 | 44 | // Outer container 45 | .progress { 46 | overflow: hidden; 47 | height: @baseLineHeight; 48 | margin-bottom: @baseLineHeight; 49 | #gradient > .vertical(#f5f5f5, #f9f9f9); 50 | .box-shadow(inset 0 1px 2px rgba(0,0,0,.1)); 51 | .border-radius(4px); 52 | } 53 | 54 | // Bar of progress 55 | .progress .bar { 56 | width: 0%; 57 | height: 100%; 58 | color: @white; 59 | float: left; 60 | font-size: 12px; 61 | text-align: center; 62 | text-shadow: 0 -1px 0 rgba(0,0,0,.25); 63 | #gradient > .vertical(#149bdf, #0480be); 64 | .box-shadow(inset 0 -1px 0 rgba(0,0,0,.15)); 65 | .box-sizing(border-box); 66 | .transition(width .6s ease); 67 | } 68 | .progress .bar + .bar { 69 | .box-shadow(inset 1px 0 0 rgba(0,0,0,.15), inset 0 -1px 0 rgba(0,0,0,.15)); 70 | } 71 | 72 | // Striped bars 73 | .progress-striped .bar { 74 | #gradient > .striped(#149bdf); 75 | .background-size(40px 40px); 76 | } 77 | 78 | // Call animation for the active one 79 | .progress.active .bar { 80 | -webkit-animation: progress-bar-stripes 2s linear infinite; 81 | -moz-animation: progress-bar-stripes 2s linear infinite; 82 | -ms-animation: progress-bar-stripes 2s linear infinite; 83 | -o-animation: progress-bar-stripes 2s linear infinite; 84 | animation: progress-bar-stripes 2s linear infinite; 85 | } 86 | 87 | 88 | 89 | // COLORS 90 | // ------ 91 | 92 | // Danger (red) 93 | .progress-danger .bar, .progress .bar-danger { 94 | #gradient > .vertical(#ee5f5b, #c43c35); 95 | } 96 | .progress-danger.progress-striped .bar, .progress-striped .bar-danger { 97 | #gradient > .striped(#ee5f5b); 98 | } 99 | 100 | // Success (green) 101 | .progress-success .bar, .progress .bar-success { 102 | #gradient > .vertical(#62c462, #57a957); 103 | } 104 | .progress-success.progress-striped .bar, .progress-striped .bar-success { 105 | #gradient > .striped(#62c462); 106 | } 107 | 108 | // Info (teal) 109 | .progress-info .bar, .progress .bar-info { 110 | #gradient > .vertical(#5bc0de, #339bb9); 111 | } 112 | .progress-info.progress-striped .bar, .progress-striped .bar-info { 113 | #gradient > .striped(#5bc0de); 114 | } 115 | 116 | // Warning (orange) 117 | .progress-warning .bar, .progress .bar-warning { 118 | #gradient > .vertical(lighten(@orange, 15%), @orange); 119 | } 120 | .progress-warning.progress-striped .bar, .progress-striped .bar-warning { 121 | #gradient > .striped(lighten(@orange, 15%)); 122 | } 123 | -------------------------------------------------------------------------------- /app/assets/css/lib/reset.less: -------------------------------------------------------------------------------- 1 | // 2 | // Modals 3 | // Adapted from http://github.com/necolas/normalize.css 4 | // -------------------------------------------------- 5 | 6 | 7 | // Display in IE6-9 and FF3 8 | // ------------------------- 9 | 10 | article, 11 | aside, 12 | details, 13 | figcaption, 14 | figure, 15 | footer, 16 | header, 17 | hgroup, 18 | nav, 19 | section { 20 | display: block; 21 | } 22 | 23 | // Display block in IE6-9 and FF3 24 | // ------------------------- 25 | 26 | audio, 27 | canvas, 28 | video { 29 | display: inline-block; 30 | *display: inline; 31 | *zoom: 1; 32 | } 33 | 34 | // Prevents modern browsers from displaying 'audio' without controls 35 | // ------------------------- 36 | 37 | audio:not([controls]) { 38 | display: none; 39 | } 40 | 41 | // Base settings 42 | // ------------------------- 43 | 44 | html { 45 | font-size: 100%; 46 | -webkit-text-size-adjust: 100%; 47 | -ms-text-size-adjust: 100%; 48 | } 49 | // Focus states 50 | a:focus { 51 | .tab-focus(); 52 | } 53 | // Hover & Active 54 | a:hover, 55 | a:active { 56 | outline: 0; 57 | } 58 | 59 | // Prevents sub and sup affecting line-height in all browsers 60 | // ------------------------- 61 | 62 | sub, 63 | sup { 64 | position: relative; 65 | font-size: 75%; 66 | line-height: 0; 67 | vertical-align: baseline; 68 | } 69 | sup { 70 | top: -0.5em; 71 | } 72 | sub { 73 | bottom: -0.25em; 74 | } 75 | 76 | // Img border in a's and image quality 77 | // ------------------------- 78 | 79 | img { 80 | /* Responsive images (ensure images don't scale beyond their parents) */ 81 | max-width: 100%; /* Part 1: Set a maxium relative to the parent */ 82 | width: auto\9; /* IE7-8 need help adjusting responsive images */ 83 | height: auto; /* Part 2: Scale the height according to the width, otherwise you get stretching */ 84 | 85 | vertical-align: middle; 86 | border: 0; 87 | -ms-interpolation-mode: bicubic; 88 | } 89 | 90 | // Prevent max-width from affecting Google Maps 91 | #map_canvas img { 92 | max-width: none; 93 | } 94 | 95 | // Forms 96 | // ------------------------- 97 | 98 | // Font size in all browsers, margin changes, misc consistency 99 | button, 100 | input, 101 | select, 102 | textarea { 103 | margin: 0; 104 | font-size: 100%; 105 | vertical-align: middle; 106 | } 107 | button, 108 | input { 109 | *overflow: visible; // Inner spacing ie IE6/7 110 | line-height: normal; // FF3/4 have !important on line-height in UA stylesheet 111 | } 112 | button::-moz-focus-inner, 113 | input::-moz-focus-inner { // Inner padding and border oddities in FF3/4 114 | padding: 0; 115 | border: 0; 116 | } 117 | button, 118 | input[type="button"], 119 | input[type="reset"], 120 | input[type="submit"] { 121 | cursor: pointer; // Cursors on all buttons applied consistently 122 | -webkit-appearance: button; // Style clickable inputs in iOS 123 | } 124 | input[type="search"] { // Appearance in Safari/Chrome 125 | -webkit-box-sizing: content-box; 126 | -moz-box-sizing: content-box; 127 | box-sizing: content-box; 128 | -webkit-appearance: textfield; 129 | } 130 | input[type="search"]::-webkit-search-decoration, 131 | input[type="search"]::-webkit-search-cancel-button { 132 | -webkit-appearance: none; // Inner-padding issues in Chrome OSX, Safari 5 133 | } 134 | textarea { 135 | overflow: auto; // Remove vertical scrollbar in IE6-9 136 | vertical-align: top; // Readability and alignment cross-browser 137 | } 138 | -------------------------------------------------------------------------------- /app/assets/css/lib/responsive-1200px-min.less: -------------------------------------------------------------------------------- 1 | // 2 | // Responsive: Large desktop and up 3 | // -------------------------------------------------- 4 | 5 | 6 | @media (min-width: 1200px) { 7 | 8 | // Fixed grid 9 | #grid > .core(@gridColumnWidth1200, @gridGutterWidth1200); 10 | 11 | // Fluid grid 12 | #grid > .fluid(@fluidGridColumnWidth1200, @fluidGridGutterWidth1200); 13 | 14 | // Input grid 15 | #grid > .input(@gridColumnWidth1200, @gridGutterWidth1200); 16 | 17 | // Thumbnails 18 | .thumbnails { 19 | margin-left: -@gridGutterWidth1200; 20 | } 21 | .thumbnails > li { 22 | margin-left: @gridGutterWidth1200; 23 | } 24 | .row-fluid .thumbnails { 25 | margin-left: 0; 26 | } 27 | 28 | } 29 | -------------------------------------------------------------------------------- /app/assets/css/lib/responsive-767px-max.less: -------------------------------------------------------------------------------- 1 | // 2 | // Responsive: Landscape phone to desktop/tablet 3 | // -------------------------------------------------- 4 | 5 | 6 | @media (max-width: 767px) { 7 | 8 | // Padding to set content in a bit 9 | body { 10 | padding-left: 20px; 11 | padding-right: 20px; 12 | } 13 | // Negative indent the now static "fixed" navbar 14 | .navbar-fixed-top, 15 | .navbar-fixed-bottom, 16 | .navbar-static-top { 17 | margin-left: -20px; 18 | margin-right: -20px; 19 | } 20 | // Remove padding on container given explicit padding set on body 21 | .container-fluid { 22 | padding: 0; 23 | } 24 | 25 | // TYPOGRAPHY 26 | // ---------- 27 | // Reset horizontal dl 28 | .dl-horizontal { 29 | dt { 30 | float: none; 31 | clear: none; 32 | width: auto; 33 | text-align: left; 34 | } 35 | dd { 36 | margin-left: 0; 37 | } 38 | } 39 | 40 | // GRID & CONTAINERS 41 | // ----------------- 42 | // Remove width from containers 43 | .container { 44 | width: auto; 45 | } 46 | // Fluid rows 47 | .row-fluid { 48 | width: 100%; 49 | } 50 | // Undo negative margin on rows and thumbnails 51 | .row, 52 | .thumbnails { 53 | margin-left: 0; 54 | } 55 | .thumbnails > li { 56 | float: none; 57 | margin-left: 0; // Reset the default margin for all li elements when no .span* classes are present 58 | } 59 | // Make all grid-sized elements block level again 60 | [class*="span"], 61 | .row-fluid [class*="span"] { 62 | float: none; 63 | display: block; 64 | width: 100%; 65 | margin-left: 0; 66 | .box-sizing(border-box); 67 | } 68 | .span12, 69 | .row-fluid .span12 { 70 | width: 100%; 71 | .box-sizing(border-box); 72 | } 73 | 74 | // FORM FIELDS 75 | // ----------- 76 | // Make span* classes full width 77 | .input-large, 78 | .input-xlarge, 79 | .input-xxlarge, 80 | input[class*="span"], 81 | select[class*="span"], 82 | textarea[class*="span"], 83 | .uneditable-input { 84 | .input-block-level(); 85 | } 86 | // But don't let it screw up prepend/append inputs 87 | .input-prepend input, 88 | .input-append input, 89 | .input-prepend input[class*="span"], 90 | .input-append input[class*="span"] { 91 | display: inline-block; // redeclare so they don't wrap to new lines 92 | width: auto; 93 | } 94 | .controls-row [class*="span"] + [class*="span"] { 95 | margin-left: 0; 96 | } 97 | 98 | // Modals 99 | .modal { 100 | position: fixed; 101 | top: 20px; 102 | left: 20px; 103 | right: 20px; 104 | width: auto; 105 | margin: 0; 106 | &.fade.in { top: auto; } 107 | } 108 | 109 | } 110 | 111 | 112 | 113 | // UP TO LANDSCAPE PHONE 114 | // --------------------- 115 | 116 | @media (max-width: 480px) { 117 | 118 | // Smooth out the collapsing/expanding nav 119 | .nav-collapse { 120 | -webkit-transform: translate3d(0, 0, 0); // activate the GPU 121 | } 122 | 123 | // Block level the page header small tag for readability 124 | .page-header h1 small { 125 | display: block; 126 | line-height: @baseLineHeight; 127 | } 128 | 129 | // Update checkboxes for iOS 130 | input[type="checkbox"], 131 | input[type="radio"] { 132 | border: 1px solid #ccc; 133 | } 134 | 135 | // Remove the horizontal form styles 136 | .form-horizontal { 137 | .control-label { 138 | float: none; 139 | width: auto; 140 | padding-top: 0; 141 | text-align: left; 142 | } 143 | // Move over all input controls and content 144 | .controls { 145 | margin-left: 0; 146 | } 147 | // Move the options list down to align with labels 148 | .control-list { 149 | padding-top: 0; // has to be padding because margin collaspes 150 | } 151 | // Move over buttons in .form-actions to align with .controls 152 | .form-actions { 153 | padding-left: 10px; 154 | padding-right: 10px; 155 | } 156 | } 157 | 158 | // Modals 159 | .modal { 160 | top: 10px; 161 | left: 10px; 162 | right: 10px; 163 | } 164 | .modal-header .close { 165 | padding: 10px; 166 | margin: -10px; 167 | } 168 | 169 | // Carousel 170 | .carousel-caption { 171 | position: static; 172 | } 173 | 174 | } 175 | -------------------------------------------------------------------------------- /app/assets/css/lib/responsive-768px-979px.less: -------------------------------------------------------------------------------- 1 | // 2 | // Responsive: Tablet to desktop 3 | // -------------------------------------------------- 4 | 5 | 6 | @media (min-width: 768px) and (max-width: 979px) { 7 | 8 | // Fixed grid 9 | #grid > .core(@gridColumnWidth768, @gridGutterWidth768); 10 | 11 | // Fluid grid 12 | #grid > .fluid(@fluidGridColumnWidth768, @fluidGridGutterWidth768); 13 | 14 | // Input grid 15 | #grid > .input(@gridColumnWidth768, @gridGutterWidth768); 16 | 17 | // No need to reset .thumbnails here since it's the same @gridGutterWidth 18 | 19 | } 20 | -------------------------------------------------------------------------------- /app/assets/css/lib/responsive-navbar.less: -------------------------------------------------------------------------------- 1 | // 2 | // Responsive: Navbar 3 | // -------------------------------------------------- 4 | 5 | 6 | // TABLETS AND BELOW 7 | // ----------------- 8 | @media (max-width: @navbarCollapseWidth) { 9 | 10 | // UNFIX THE TOPBAR 11 | // ---------------- 12 | // Remove any padding from the body 13 | body { 14 | padding-top: 0; 15 | } 16 | // Unfix the navbars 17 | .navbar-fixed-top, 18 | .navbar-fixed-bottom { 19 | position: static; 20 | } 21 | .navbar-fixed-top { 22 | margin-bottom: @baseLineHeight; 23 | } 24 | .navbar-fixed-bottom { 25 | margin-top: @baseLineHeight; 26 | } 27 | .navbar-fixed-top .navbar-inner, 28 | .navbar-fixed-bottom .navbar-inner { 29 | padding: 5px; 30 | } 31 | .navbar .container { 32 | width: auto; 33 | padding: 0; 34 | } 35 | // Account for brand name 36 | .navbar .brand { 37 | padding-left: 10px; 38 | padding-right: 10px; 39 | margin: 0 0 0 -5px; 40 | } 41 | 42 | // COLLAPSIBLE NAVBAR 43 | // ------------------ 44 | // Nav collapse clears brand 45 | .nav-collapse { 46 | clear: both; 47 | } 48 | // Block-level the nav 49 | .nav-collapse .nav { 50 | float: none; 51 | margin: 0 0 (@baseLineHeight / 2); 52 | } 53 | .nav-collapse .nav > li { 54 | float: none; 55 | } 56 | .nav-collapse .nav > li > a { 57 | margin-bottom: 2px; 58 | } 59 | .nav-collapse .nav > .divider-vertical { 60 | display: none; 61 | } 62 | .nav-collapse .nav .nav-header { 63 | color: @navbarText; 64 | text-shadow: none; 65 | } 66 | // Nav and dropdown links in navbar 67 | .nav-collapse .nav > li > a, 68 | .nav-collapse .dropdown-menu a { 69 | padding: 9px 15px; 70 | font-weight: bold; 71 | color: @navbarLinkColor; 72 | .border-radius(3px); 73 | } 74 | // Buttons 75 | .nav-collapse .btn { 76 | padding: 4px 10px 4px; 77 | font-weight: normal; 78 | .border-radius(4px); 79 | } 80 | .nav-collapse .dropdown-menu li + li a { 81 | margin-bottom: 2px; 82 | } 83 | .nav-collapse .nav > li > a:hover, 84 | .nav-collapse .dropdown-menu a:hover { 85 | background-color: @navbarBackground; 86 | } 87 | .navbar-inverse .nav-collapse .nav > li > a:hover, 88 | .navbar-inverse .nav-collapse .dropdown-menu a:hover { 89 | background-color: @navbarInverseBackground; 90 | } 91 | // Buttons in the navbar 92 | .nav-collapse.in .btn-group { 93 | margin-top: 5px; 94 | padding: 0; 95 | } 96 | // Dropdowns in the navbar 97 | .nav-collapse .dropdown-menu { 98 | position: static; 99 | top: auto; 100 | left: auto; 101 | float: none; 102 | display: block; 103 | max-width: none; 104 | margin: 0 15px; 105 | padding: 0; 106 | background-color: transparent; 107 | border: none; 108 | .border-radius(0); 109 | .box-shadow(none); 110 | } 111 | .nav-collapse .dropdown-menu:before, 112 | .nav-collapse .dropdown-menu:after { 113 | display: none; 114 | } 115 | .nav-collapse .dropdown-menu .divider { 116 | display: none; 117 | } 118 | .nav-collapse .nav > li > .dropdown-menu { 119 | &:before, 120 | &:after { 121 | display: none; 122 | } 123 | } 124 | // Forms in navbar 125 | .nav-collapse .navbar-form, 126 | .nav-collapse .navbar-search { 127 | float: none; 128 | padding: (@baseLineHeight / 2) 15px; 129 | margin: (@baseLineHeight / 2) 0; 130 | border-top: 1px solid @navbarBackground; 131 | border-bottom: 1px solid @navbarBackground; 132 | .box-shadow(inset 0 1px 0 rgba(255,255,255,.1), 0 1px 0 rgba(255,255,255,.1)); 133 | } 134 | .navbar-inverse .nav-collapse .navbar-form, 135 | .navbar-inverse .nav-collapse .navbar-search { 136 | border-top-color: @navbarInverseBackground; 137 | border-bottom-color: @navbarInverseBackground; 138 | } 139 | // Pull right (secondary) nav content 140 | .navbar .nav-collapse .nav.pull-right { 141 | float: none; 142 | margin-left: 0; 143 | } 144 | // Hide everything in the navbar save .brand and toggle button */ 145 | .nav-collapse, 146 | .nav-collapse.collapse { 147 | overflow: hidden; 148 | height: 0; 149 | } 150 | // Navbar button 151 | .navbar .btn-navbar { 152 | display: block; 153 | } 154 | 155 | // STATIC NAVBAR 156 | // ------------- 157 | .navbar-static .navbar-inner { 158 | padding-left: 10px; 159 | padding-right: 10px; 160 | } 161 | 162 | 163 | } 164 | 165 | 166 | // DEFAULT DESKTOP 167 | // --------------- 168 | 169 | @media (min-width: 980px) { 170 | 171 | // Required to make the collapsing navbar work on regular desktops 172 | .nav-collapse.collapse { 173 | height: auto !important; 174 | overflow: visible !important; 175 | } 176 | 177 | } 178 | -------------------------------------------------------------------------------- /app/assets/css/lib/responsive-utilities.less: -------------------------------------------------------------------------------- 1 | // 2 | // Responsive: Utility classes 3 | // -------------------------------------------------- 4 | 5 | 6 | // Hide from screenreaders and browsers 7 | // Credit: HTML5 Boilerplate 8 | .hidden { 9 | display: none; 10 | visibility: hidden; 11 | } 12 | 13 | // Visibility utilities 14 | 15 | // For desktops 16 | .visible-phone { display: none !important; } 17 | .visible-tablet { display: none !important; } 18 | .hidden-phone { } 19 | .hidden-tablet { } 20 | .hidden-desktop { display: none !important; } 21 | .visible-desktop { display: inherit !important; } 22 | 23 | // Tablets & small desktops only 24 | @media (min-width: 768px) and (max-width: 979px) { 25 | // Hide everything else 26 | .hidden-desktop { display: inherit !important; } 27 | .visible-desktop { display: none !important ; } 28 | // Show 29 | .visible-tablet { display: inherit !important; } 30 | // Hide 31 | .hidden-tablet { display: none !important; } 32 | } 33 | 34 | // Phones only 35 | @media (max-width: 767px) { 36 | // Hide everything else 37 | .hidden-desktop { display: inherit !important; } 38 | .visible-desktop { display: none !important; } 39 | // Show 40 | .visible-phone { display: inherit !important; } // Use inherit to restore previous behavior 41 | // Hide 42 | .hidden-phone { display: none !important; } 43 | } 44 | -------------------------------------------------------------------------------- /app/assets/css/lib/responsive.less: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap Responsive v2.1.1 3 | * 4 | * Copyright 2012 Twitter, Inc 5 | * Licensed under the Apache License v2.0 6 | * http://www.apache.org/licenses/LICENSE-2.0 7 | * 8 | * Designed and built with all the love in the world @twitter by @mdo and @fat. 9 | */ 10 | 11 | 12 | // Responsive.less 13 | // For phone and tablet devices 14 | // ------------------------------------------------------------- 15 | 16 | 17 | // REPEAT VARIABLES & MIXINS 18 | // ------------------------- 19 | // Required since we compile the responsive stuff separately 20 | 21 | @import "variables.less"; // Modify this for custom colors, font-sizes, etc 22 | @import "mixins.less"; 23 | 24 | 25 | // RESPONSIVE CLASSES 26 | // ------------------ 27 | 28 | @import "responsive-utilities.less"; 29 | 30 | 31 | // MEDIA QUERIES 32 | // ------------------ 33 | 34 | // Large desktops 35 | @import "responsive-1200px-min.less"; 36 | 37 | // Tablets to regular desktops 38 | @import "responsive-768px-979px.less"; 39 | 40 | // Phones to portrait tablets and narrow desktops 41 | @import "responsive-767px-max.less"; 42 | 43 | 44 | // RESPONSIVE NAVBAR 45 | // ------------------ 46 | 47 | // From 979px and below, show a button to toggle navbar contents 48 | @import "responsive-navbar.less"; 49 | -------------------------------------------------------------------------------- /app/assets/css/lib/scaffolding.less: -------------------------------------------------------------------------------- 1 | // 2 | // Scaffolding 3 | // -------------------------------------------------- 4 | 5 | 6 | // Body reset 7 | // ------------------------- 8 | 9 | body { 10 | margin: 0; 11 | font-family: @baseFontFamily; 12 | font-size: @baseFontSize; 13 | line-height: @baseLineHeight; 14 | color: @textColor; 15 | background-color: @bodyBackground; 16 | } 17 | 18 | 19 | // Links 20 | // ------------------------- 21 | 22 | a { 23 | color: @linkColor; 24 | text-decoration: none; 25 | } 26 | a:hover { 27 | color: @linkColorHover; 28 | text-decoration: underline; 29 | } 30 | 31 | 32 | // Images 33 | // ------------------------- 34 | 35 | // Rounded corners 36 | .img-rounded { 37 | .border-radius(6px); 38 | } 39 | 40 | // Add polaroid-esque trim 41 | .img-polaroid { 42 | padding: 4px; 43 | background-color: #fff; 44 | border: 1px solid #ccc; 45 | border: 1px solid rgba(0,0,0,.2); 46 | .box-shadow(0 1px 3px rgba(0,0,0,.1)); 47 | } 48 | 49 | // Perfect circle 50 | .img-circle { 51 | .border-radius(500px); // crank the border-radius so it works with most reasonably sized images 52 | } 53 | -------------------------------------------------------------------------------- /app/assets/css/lib/tables.less: -------------------------------------------------------------------------------- 1 | // 2 | // Tables 3 | // -------------------------------------------------- 4 | 5 | 6 | // BASE TABLES 7 | // ----------------- 8 | 9 | table { 10 | max-width: 100%; 11 | background-color: @tableBackground; 12 | border-collapse: collapse; 13 | border-spacing: 0; 14 | } 15 | 16 | // BASELINE STYLES 17 | // --------------- 18 | 19 | .table { 20 | width: 100%; 21 | margin-bottom: @baseLineHeight; 22 | // Cells 23 | th, 24 | td { 25 | padding: 8px; 26 | line-height: @baseLineHeight; 27 | text-align: left; 28 | vertical-align: top; 29 | border-top: 1px solid @tableBorder; 30 | } 31 | th { 32 | font-weight: bold; 33 | } 34 | // Bottom align for column headings 35 | thead th { 36 | vertical-align: bottom; 37 | } 38 | // Remove top border from thead by default 39 | caption + thead tr:first-child th, 40 | caption + thead tr:first-child td, 41 | colgroup + thead tr:first-child th, 42 | colgroup + thead tr:first-child td, 43 | thead:first-child tr:first-child th, 44 | thead:first-child tr:first-child td { 45 | border-top: 0; 46 | } 47 | // Account for multiple tbody instances 48 | tbody + tbody { 49 | border-top: 2px solid @tableBorder; 50 | } 51 | } 52 | 53 | 54 | 55 | // CONDENSED TABLE W/ HALF PADDING 56 | // ------------------------------- 57 | 58 | .table-condensed { 59 | th, 60 | td { 61 | padding: 4px 5px; 62 | } 63 | } 64 | 65 | 66 | // BORDERED VERSION 67 | // ---------------- 68 | 69 | .table-bordered { 70 | border: 1px solid @tableBorder; 71 | border-collapse: separate; // Done so we can round those corners! 72 | *border-collapse: collapse; // IE7 can't round corners anyway 73 | border-left: 0; 74 | .border-radius(4px); 75 | th, 76 | td { 77 | border-left: 1px solid @tableBorder; 78 | } 79 | // Prevent a double border 80 | caption + thead tr:first-child th, 81 | caption + tbody tr:first-child th, 82 | caption + tbody tr:first-child td, 83 | colgroup + thead tr:first-child th, 84 | colgroup + tbody tr:first-child th, 85 | colgroup + tbody tr:first-child td, 86 | thead:first-child tr:first-child th, 87 | tbody:first-child tr:first-child th, 88 | tbody:first-child tr:first-child td { 89 | border-top: 0; 90 | } 91 | // For first th or td in the first row in the first thead or tbody 92 | thead:first-child tr:first-child th:first-child, 93 | tbody:first-child tr:first-child td:first-child { 94 | -webkit-border-top-left-radius: 4px; 95 | border-top-left-radius: 4px; 96 | -moz-border-radius-topleft: 4px; 97 | } 98 | thead:first-child tr:first-child th:last-child, 99 | tbody:first-child tr:first-child td:last-child { 100 | -webkit-border-top-right-radius: 4px; 101 | border-top-right-radius: 4px; 102 | -moz-border-radius-topright: 4px; 103 | } 104 | // For first th or td in the first row in the first thead or tbody 105 | thead:last-child tr:last-child th:first-child, 106 | tbody:last-child tr:last-child td:first-child, 107 | tfoot:last-child tr:last-child td:first-child { 108 | .border-radius(0 0 0 4px); 109 | -webkit-border-bottom-left-radius: 4px; 110 | border-bottom-left-radius: 4px; 111 | -moz-border-radius-bottomleft: 4px; 112 | } 113 | thead:last-child tr:last-child th:last-child, 114 | tbody:last-child tr:last-child td:last-child, 115 | tfoot:last-child tr:last-child td:last-child { 116 | -webkit-border-bottom-right-radius: 4px; 117 | border-bottom-right-radius: 4px; 118 | -moz-border-radius-bottomright: 4px; 119 | } 120 | 121 | // Special fixes to round the left border on the first td/th 122 | caption + thead tr:first-child th:first-child, 123 | caption + tbody tr:first-child td:first-child, 124 | colgroup + thead tr:first-child th:first-child, 125 | colgroup + tbody tr:first-child td:first-child { 126 | -webkit-border-top-left-radius: 4px; 127 | border-top-left-radius: 4px; 128 | -moz-border-radius-topleft: 4px; 129 | } 130 | caption + thead tr:first-child th:last-child, 131 | caption + tbody tr:first-child td:last-child, 132 | colgroup + thead tr:first-child th:last-child, 133 | colgroup + tbody tr:first-child td:last-child { 134 | -webkit-border-top-right-radius: 4px; 135 | border-top-right-radius: 4px; 136 | -moz-border-radius-topleft: 4px; 137 | } 138 | 139 | } 140 | 141 | 142 | 143 | 144 | // ZEBRA-STRIPING 145 | // -------------- 146 | 147 | // Default zebra-stripe styles (alternating gray and transparent backgrounds) 148 | .table-striped { 149 | tbody { 150 | tr:nth-child(odd) td, 151 | tr:nth-child(odd) th { 152 | background-color: @tableBackgroundAccent; 153 | } 154 | } 155 | } 156 | 157 | 158 | // HOVER EFFECT 159 | // ------------ 160 | // Placed here since it has to come after the potential zebra striping 161 | .table-hover { 162 | tbody { 163 | tr:hover td, 164 | tr:hover th { 165 | background-color: @tableBackgroundHover; 166 | } 167 | } 168 | } 169 | 170 | 171 | // TABLE CELL SIZING 172 | // ----------------- 173 | 174 | // Reset default grid behavior 175 | table [class*=span], 176 | .row-fluid table [class*=span] { 177 | display: table-cell; 178 | float: none; // undo default grid column styles 179 | margin-left: 0; // undo default grid column styles 180 | } 181 | 182 | // Change the column widths to account for td/th padding 183 | .table { 184 | .span1 { .tableColumns(1); } 185 | .span2 { .tableColumns(2); } 186 | .span3 { .tableColumns(3); } 187 | .span4 { .tableColumns(4); } 188 | .span5 { .tableColumns(5); } 189 | .span6 { .tableColumns(6); } 190 | .span7 { .tableColumns(7); } 191 | .span8 { .tableColumns(8); } 192 | .span9 { .tableColumns(9); } 193 | .span10 { .tableColumns(10); } 194 | .span11 { .tableColumns(11); } 195 | .span12 { .tableColumns(12); } 196 | .span13 { .tableColumns(13); } 197 | .span14 { .tableColumns(14); } 198 | .span15 { .tableColumns(15); } 199 | .span16 { .tableColumns(16); } 200 | .span17 { .tableColumns(17); } 201 | .span18 { .tableColumns(18); } 202 | .span19 { .tableColumns(19); } 203 | .span20 { .tableColumns(20); } 204 | .span21 { .tableColumns(21); } 205 | .span22 { .tableColumns(22); } 206 | .span23 { .tableColumns(23); } 207 | .span24 { .tableColumns(24); } 208 | } 209 | 210 | 211 | 212 | // TABLE BACKGROUNDS 213 | // ----------------- 214 | // Exact selectors below required to override .table-striped 215 | 216 | .table tbody tr { 217 | &.success td { 218 | background-color: @successBackground; 219 | } 220 | &.error td { 221 | background-color: @errorBackground; 222 | } 223 | &.warning td { 224 | background-color: @warningBackground; 225 | } 226 | &.info td { 227 | background-color: @infoBackground; 228 | } 229 | } 230 | 231 | // Hover states for .table-hover 232 | .table-hover tbody tr { 233 | &.success:hover td { 234 | background-color: darken(@successBackground, 5%); 235 | } 236 | &.error:hover td { 237 | background-color: darken(@errorBackground, 5%); 238 | } 239 | &.warning:hover td { 240 | background-color: darken(@warningBackground, 5%); 241 | } 242 | &.info:hover td { 243 | background-color: darken(@infoBackground, 5%); 244 | } 245 | } 246 | -------------------------------------------------------------------------------- /app/assets/css/lib/thumbnails.less: -------------------------------------------------------------------------------- 1 | // 2 | // Thumbnails 3 | // -------------------------------------------------- 4 | 5 | 6 | // Note: `.thumbnails` and `.thumbnails > li` are overriden in responsive files 7 | 8 | // Make wrapper ul behave like the grid 9 | .thumbnails { 10 | margin-left: -@gridGutterWidth; 11 | list-style: none; 12 | .clearfix(); 13 | } 14 | // Fluid rows have no left margin 15 | .row-fluid .thumbnails { 16 | margin-left: 0; 17 | } 18 | 19 | // Float li to make thumbnails appear in a row 20 | .thumbnails > li { 21 | float: left; // Explicity set the float since we don't require .span* classes 22 | margin-bottom: @baseLineHeight; 23 | margin-left: @gridGutterWidth; 24 | } 25 | 26 | // The actual thumbnail (can be `a` or `div`) 27 | .thumbnail { 28 | display: block; 29 | padding: 4px; 30 | line-height: @baseLineHeight; 31 | border: 1px solid #ddd; 32 | .border-radius(4px); 33 | .box-shadow(0 1px 3px rgba(0,0,0,.055)); 34 | .transition(all .2s ease-in-out); 35 | } 36 | // Add a hover state for linked versions only 37 | a.thumbnail:hover { 38 | border-color: @linkColor; 39 | .box-shadow(0 1px 4px rgba(0,105,214,.25)); 40 | } 41 | 42 | // Images and captions 43 | .thumbnail > img { 44 | display: block; 45 | max-width: 100%; 46 | margin-left: auto; 47 | margin-right: auto; 48 | } 49 | .thumbnail .caption { 50 | padding: 9px; 51 | color: @gray; 52 | } 53 | -------------------------------------------------------------------------------- /app/assets/css/lib/tooltip.less: -------------------------------------------------------------------------------- 1 | // 2 | // Tooltips 3 | // -------------------------------------------------- 4 | 5 | 6 | // Base class 7 | .tooltip { 8 | position: absolute; 9 | z-index: @zindexTooltip; 10 | display: block; 11 | visibility: visible; 12 | padding: 5px; 13 | font-size: 11px; 14 | .opacity(0); 15 | &.in { .opacity(80); } 16 | &.top { margin-top: -3px; } 17 | &.right { margin-left: 3px; } 18 | &.bottom { margin-top: 3px; } 19 | &.left { margin-left: -3px; } 20 | } 21 | 22 | // Wrapper for the tooltip content 23 | .tooltip-inner { 24 | max-width: 200px; 25 | padding: 3px 8px; 26 | color: @tooltipColor; 27 | text-align: center; 28 | text-decoration: none; 29 | background-color: @tooltipBackground; 30 | .border-radius(4px); 31 | } 32 | 33 | // Arrows 34 | .tooltip-arrow { 35 | position: absolute; 36 | width: 0; 37 | height: 0; 38 | border-color: transparent; 39 | border-style: solid; 40 | } 41 | .tooltip { 42 | &.top .tooltip-arrow { 43 | bottom: 0; 44 | left: 50%; 45 | margin-left: -@tooltipArrowWidth; 46 | border-width: @tooltipArrowWidth @tooltipArrowWidth 0; 47 | border-top-color: @tooltipArrowColor; 48 | } 49 | &.right .tooltip-arrow { 50 | top: 50%; 51 | left: 0; 52 | margin-top: -@tooltipArrowWidth; 53 | border-width: @tooltipArrowWidth @tooltipArrowWidth @tooltipArrowWidth 0; 54 | border-right-color: @tooltipArrowColor; 55 | } 56 | &.left .tooltip-arrow { 57 | top: 50%; 58 | right: 0; 59 | margin-top: -@tooltipArrowWidth; 60 | border-width: @tooltipArrowWidth 0 @tooltipArrowWidth @tooltipArrowWidth; 61 | border-left-color: @tooltipArrowColor; 62 | } 63 | &.bottom .tooltip-arrow { 64 | top: 0; 65 | left: 50%; 66 | margin-left: -@tooltipArrowWidth; 67 | border-width: 0 @tooltipArrowWidth @tooltipArrowWidth; 68 | border-bottom-color: @tooltipArrowColor; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /app/assets/css/lib/type.less: -------------------------------------------------------------------------------- 1 | // 2 | // Typography 3 | // -------------------------------------------------- 4 | 5 | 6 | // Body text 7 | // ------------------------- 8 | 9 | p { 10 | margin: 0 0 @baseLineHeight / 2; 11 | } 12 | .lead { 13 | margin-bottom: @baseLineHeight; 14 | font-size: @baseFontSize * 1.5; 15 | font-weight: 200; 16 | line-height: @baseLineHeight * 1.5; 17 | } 18 | 19 | 20 | // Emphasis & misc 21 | // ------------------------- 22 | 23 | small { 24 | font-size: 85%; // Ex: 14px base font * 85% = about 12px 25 | } 26 | strong { 27 | font-weight: bold; 28 | } 29 | em { 30 | font-style: italic; 31 | } 32 | cite { 33 | font-style: normal; 34 | } 35 | 36 | // Utility classes 37 | .muted { 38 | color: @grayLight; 39 | } 40 | .text-warning { 41 | color: @warningText; 42 | } 43 | .text-error { 44 | color: @errorText; 45 | } 46 | .text-info { 47 | color: @infoText; 48 | } 49 | .text-success { 50 | color: @successText; 51 | } 52 | 53 | 54 | // Headings 55 | // ------------------------- 56 | 57 | h1, h2, h3, h4, h5, h6 { 58 | margin: (@baseLineHeight / 2) 0; 59 | font-family: @headingsFontFamily; 60 | font-weight: @headingsFontWeight; 61 | line-height: 1; 62 | color: @headingsColor; 63 | text-rendering: optimizelegibility; // Fix the character spacing for headings 64 | small { 65 | font-weight: normal; 66 | line-height: 1; 67 | color: @grayLight; 68 | } 69 | } 70 | h1 { font-size: 36px; line-height: 40px; } 71 | h2 { font-size: 30px; line-height: 40px; } 72 | h3 { font-size: 24px; line-height: 40px; } 73 | h4 { font-size: 18px; line-height: 20px; } 74 | h5 { font-size: 14px; line-height: 20px; } 75 | h6 { font-size: 12px; line-height: 20px; } 76 | 77 | h1 small { font-size: 24px; } 78 | h2 small { font-size: 18px; } 79 | h3 small { font-size: 14px; } 80 | h4 small { font-size: 14px; } 81 | 82 | 83 | // Page header 84 | // ------------------------- 85 | 86 | .page-header { 87 | padding-bottom: (@baseLineHeight / 2) - 1; 88 | margin: @baseLineHeight 0 (@baseLineHeight * 1.5); 89 | border-bottom: 1px solid @grayLighter; 90 | } 91 | 92 | 93 | 94 | // Lists 95 | // -------------------------------------------------- 96 | 97 | // Unordered and Ordered lists 98 | ul, ol { 99 | padding: 0; 100 | margin: 0 0 @baseLineHeight / 2 25px; 101 | } 102 | ul ul, 103 | ul ol, 104 | ol ol, 105 | ol ul { 106 | margin-bottom: 0; 107 | } 108 | li { 109 | line-height: @baseLineHeight; 110 | } 111 | ul.unstyled, 112 | ol.unstyled { 113 | margin-left: 0; 114 | list-style: none; 115 | } 116 | 117 | // Description Lists 118 | dl { 119 | margin-bottom: @baseLineHeight; 120 | } 121 | dt, 122 | dd { 123 | line-height: @baseLineHeight; 124 | } 125 | dt { 126 | font-weight: bold; 127 | } 128 | dd { 129 | margin-left: @baseLineHeight / 2; 130 | } 131 | // Horizontal layout (like forms) 132 | .dl-horizontal { 133 | .clearfix(); // Ensure dl clears floats if empty dd elements present 134 | dt { 135 | float: left; 136 | width: @horizontalComponentOffset - 20; 137 | clear: left; 138 | text-align: right; 139 | .text-overflow(); 140 | } 141 | dd { 142 | margin-left: @horizontalComponentOffset; 143 | } 144 | } 145 | 146 | // MISC 147 | // ---- 148 | 149 | // Horizontal rules 150 | hr { 151 | margin: @baseLineHeight 0; 152 | border: 0; 153 | border-top: 1px solid @hrBorder; 154 | border-bottom: 1px solid @white; 155 | } 156 | 157 | // Abbreviations and acronyms 158 | abbr[title] { 159 | cursor: help; 160 | border-bottom: 1px dotted @grayLight; 161 | } 162 | abbr.initialism { 163 | font-size: 90%; 164 | text-transform: uppercase; 165 | } 166 | 167 | // Blockquotes 168 | blockquote { 169 | padding: 0 0 0 15px; 170 | margin: 0 0 @baseLineHeight; 171 | border-left: 5px solid @grayLighter; 172 | p { 173 | margin-bottom: 0; 174 | #font > .shorthand(16px,300,@baseLineHeight * 1.25); 175 | } 176 | small { 177 | display: block; 178 | line-height: @baseLineHeight; 179 | color: @grayLight; 180 | &:before { 181 | content: '\2014 \00A0'; 182 | } 183 | } 184 | 185 | // Float right with text-align: right 186 | &.pull-right { 187 | float: right; 188 | padding-right: 15px; 189 | padding-left: 0; 190 | border-right: 5px solid @grayLighter; 191 | border-left: 0; 192 | p, 193 | small { 194 | text-align: right; 195 | } 196 | small { 197 | &:before { 198 | content: ''; 199 | } 200 | &:after { 201 | content: '\00A0 \2014'; 202 | } 203 | } 204 | } 205 | } 206 | 207 | // Quotes 208 | q:before, 209 | q:after, 210 | blockquote:before, 211 | blockquote:after { 212 | content: ""; 213 | } 214 | 215 | // Addresses 216 | address { 217 | display: block; 218 | margin-bottom: @baseLineHeight; 219 | font-style: normal; 220 | line-height: @baseLineHeight; 221 | } 222 | -------------------------------------------------------------------------------- /app/assets/css/lib/utilities.less: -------------------------------------------------------------------------------- 1 | // 2 | // Utility classes 3 | // -------------------------------------------------- 4 | 5 | 6 | // Quick floats 7 | .pull-right { 8 | float: right; 9 | } 10 | .pull-left { 11 | float: left; 12 | } 13 | 14 | // Toggling content 15 | .hide { 16 | display: none; 17 | } 18 | .show { 19 | display: block; 20 | } 21 | 22 | // Visibility 23 | .invisible { 24 | visibility: hidden; 25 | } 26 | 27 | // For Affix plugin 28 | .affix { 29 | position: fixed; 30 | } 31 | -------------------------------------------------------------------------------- /app/assets/css/lib/wells.less: -------------------------------------------------------------------------------- 1 | // 2 | // Wells 3 | // -------------------------------------------------- 4 | 5 | 6 | // Base class 7 | .well { 8 | min-height: 20px; 9 | padding: 19px; 10 | margin-bottom: 20px; 11 | background-color: @wellBackground; 12 | border: 1px solid darken(@wellBackground, 7%); 13 | .border-radius(4px); 14 | .box-shadow(inset 0 1px 1px rgba(0,0,0,.05)); 15 | blockquote { 16 | border-color: #ddd; 17 | border-color: rgba(0,0,0,.15); 18 | } 19 | } 20 | 21 | // Sizes 22 | .well-large { 23 | padding: 24px; 24 | .border-radius(6px); 25 | } 26 | .well-small { 27 | padding: 9px; 28 | .border-radius(3px); 29 | } 30 | -------------------------------------------------------------------------------- /app/assets/css/main.css.less: -------------------------------------------------------------------------------- 1 | 2 | @import "./lib/bootstrap.less"; 3 | 4 | body { 5 | padding-top: 60px; 6 | padding-bottom: 40px; 7 | background-image: url("/images/grey.png"); 8 | background-repeat: repeat; 9 | } 10 | 11 | @import "./lib/responsive.less"; 12 | 13 | .jumbo { 14 | 15 | background-color: rgba(0, 0, 0, 0.1); 16 | .border-radius(5px); 17 | margin-top: -60px; 18 | 19 | .container(); 20 | padding-top: 60px; 21 | padding-bottom: 40px; 22 | margin-bottom: 30px; 23 | 24 | .border-radius(6px); 25 | h1 { 26 | margin-bottom: 0; 27 | font-size: 60px; 28 | line-height: 1; 29 | color: @heroUnitHeadingColor; 30 | letter-spacing: -1px; 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /app/assets/js/bootstrap.js: -------------------------------------------------------------------------------- 1 | //= require ./jquery-1.8.2.js 2 | //= require ./bootstrap/bootstrap-transition.js 3 | //= require ./bootstrap/bootstrap-alert.js 4 | //= require ./bootstrap/bootstrap-modal.js 5 | //= require ./bootstrap/bootstrap-dropdown.js 6 | //= require ./bootstrap/bootstrap-scrollspy.js 7 | //= require ./bootstrap/bootstrap-tab.js 8 | //= require ./bootstrap/bootstrap-tooltip.js 9 | //= require ./bootstrap/bootstrap-popover.js 10 | //= require ./bootstrap/bootstrap-button.js 11 | //= require ./bootstrap/bootstrap-collapse.js 12 | //= require ./bootstrap/bootstrap-carousel.js 13 | //= require ./bootstrap/bootstrap-typeahead.js 14 | -------------------------------------------------------------------------------- /app/assets/js/bootstrap/bootstrap-affix.js: -------------------------------------------------------------------------------- 1 | /* ========================================================== 2 | * bootstrap-affix.js v2.1.1 3 | * http://twitter.github.com/bootstrap/javascript.html#affix 4 | * ========================================================== 5 | * Copyright 2012 Twitter, Inc. 6 | * 7 | * Licensed under the Apache License, Version 2.0 (the "License"); 8 | * you may not use this file except in compliance with the License. 9 | * You may obtain a copy of the License at 10 | * 11 | * http://www.apache.org/licenses/LICENSE-2.0 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | * ========================================================== */ 19 | 20 | 21 | !function ($) { 22 | 23 | "use strict"; // jshint ;_; 24 | 25 | 26 | /* AFFIX CLASS DEFINITION 27 | * ====================== */ 28 | 29 | var Affix = function (element, options) { 30 | this.options = $.extend({}, $.fn.affix.defaults, options) 31 | this.$window = $(window).on('scroll.affix.data-api', $.proxy(this.checkPosition, this)) 32 | this.$element = $(element) 33 | this.checkPosition() 34 | } 35 | 36 | Affix.prototype.checkPosition = function () { 37 | if (!this.$element.is(':visible')) return 38 | 39 | var scrollHeight = $(document).height() 40 | , scrollTop = this.$window.scrollTop() 41 | , position = this.$element.offset() 42 | , offset = this.options.offset 43 | , offsetBottom = offset.bottom 44 | , offsetTop = offset.top 45 | , reset = 'affix affix-top affix-bottom' 46 | , affix 47 | 48 | if (typeof offset != 'object') offsetBottom = offsetTop = offset 49 | if (typeof offsetTop == 'function') offsetTop = offset.top() 50 | if (typeof offsetBottom == 'function') offsetBottom = offset.bottom() 51 | 52 | affix = this.unpin != null && (scrollTop + this.unpin <= position.top) ? 53 | false : offsetBottom != null && (position.top + this.$element.height() >= scrollHeight - offsetBottom) ? 54 | 'bottom' : offsetTop != null && scrollTop <= offsetTop ? 55 | 'top' : false 56 | 57 | if (this.affixed === affix) return 58 | 59 | this.affixed = affix 60 | this.unpin = affix == 'bottom' ? position.top - scrollTop : null 61 | 62 | this.$element.removeClass(reset).addClass('affix' + (affix ? '-' + affix : '')) 63 | } 64 | 65 | 66 | /* AFFIX PLUGIN DEFINITION 67 | * ======================= */ 68 | 69 | $.fn.affix = function (option) { 70 | return this.each(function () { 71 | var $this = $(this) 72 | , data = $this.data('affix') 73 | , options = typeof option == 'object' && option 74 | if (!data) $this.data('affix', (data = new Affix(this, options))) 75 | if (typeof option == 'string') data[option]() 76 | }) 77 | } 78 | 79 | $.fn.affix.Constructor = Affix 80 | 81 | $.fn.affix.defaults = { 82 | offset: 0 83 | } 84 | 85 | 86 | /* AFFIX DATA-API 87 | * ============== */ 88 | 89 | $(window).on('load', function () { 90 | $('[data-spy="affix"]').each(function () { 91 | var $spy = $(this) 92 | , data = $spy.data() 93 | 94 | data.offset = data.offset || {} 95 | 96 | data.offsetBottom && (data.offset.bottom = data.offsetBottom) 97 | data.offsetTop && (data.offset.top = data.offsetTop) 98 | 99 | $spy.affix(data) 100 | }) 101 | }) 102 | 103 | 104 | }(window.jQuery); -------------------------------------------------------------------------------- /app/assets/js/bootstrap/bootstrap-alert.js: -------------------------------------------------------------------------------- 1 | /* ========================================================== 2 | * bootstrap-alert.js v2.1.1 3 | * http://twitter.github.com/bootstrap/javascript.html#alerts 4 | * ========================================================== 5 | * Copyright 2012 Twitter, Inc. 6 | * 7 | * Licensed under the Apache License, Version 2.0 (the "License"); 8 | * you may not use this file except in compliance with the License. 9 | * You may obtain a copy of the License at 10 | * 11 | * http://www.apache.org/licenses/LICENSE-2.0 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | * ========================================================== */ 19 | 20 | 21 | !function ($) { 22 | 23 | "use strict"; // jshint ;_; 24 | 25 | 26 | /* ALERT CLASS DEFINITION 27 | * ====================== */ 28 | 29 | var dismiss = '[data-dismiss="alert"]' 30 | , Alert = function (el) { 31 | $(el).on('click', dismiss, this.close) 32 | } 33 | 34 | Alert.prototype.close = function (e) { 35 | var $this = $(this) 36 | , selector = $this.attr('data-target') 37 | , $parent 38 | 39 | if (!selector) { 40 | selector = $this.attr('href') 41 | selector = selector && selector.replace(/.*(?=#[^\s]*$)/, '') //strip for ie7 42 | } 43 | 44 | $parent = $(selector) 45 | 46 | e && e.preventDefault() 47 | 48 | $parent.length || ($parent = $this.hasClass('alert') ? $this : $this.parent()) 49 | 50 | $parent.trigger(e = $.Event('close')) 51 | 52 | if (e.isDefaultPrevented()) return 53 | 54 | $parent.removeClass('in') 55 | 56 | function removeElement() { 57 | $parent 58 | .trigger('closed') 59 | .remove() 60 | } 61 | 62 | $.support.transition && $parent.hasClass('fade') ? 63 | $parent.on($.support.transition.end, removeElement) : 64 | removeElement() 65 | } 66 | 67 | 68 | /* ALERT PLUGIN DEFINITION 69 | * ======================= */ 70 | 71 | $.fn.alert = function (option) { 72 | return this.each(function () { 73 | var $this = $(this) 74 | , data = $this.data('alert') 75 | if (!data) $this.data('alert', (data = new Alert(this))) 76 | if (typeof option == 'string') data[option].call($this) 77 | }) 78 | } 79 | 80 | $.fn.alert.Constructor = Alert 81 | 82 | 83 | /* ALERT DATA-API 84 | * ============== */ 85 | 86 | $(function () { 87 | $('body').on('click.alert.data-api', dismiss, Alert.prototype.close) 88 | }) 89 | 90 | }(window.jQuery); -------------------------------------------------------------------------------- /app/assets/js/bootstrap/bootstrap-button.js: -------------------------------------------------------------------------------- 1 | /* ============================================================ 2 | * bootstrap-button.js v2.1.1 3 | * http://twitter.github.com/bootstrap/javascript.html#buttons 4 | * ============================================================ 5 | * Copyright 2012 Twitter, Inc. 6 | * 7 | * Licensed under the Apache License, Version 2.0 (the "License"); 8 | * you may not use this file except in compliance with the License. 9 | * You may obtain a copy of the License at 10 | * 11 | * http://www.apache.org/licenses/LICENSE-2.0 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | * ============================================================ */ 19 | 20 | 21 | !function ($) { 22 | 23 | "use strict"; // jshint ;_; 24 | 25 | 26 | /* BUTTON PUBLIC CLASS DEFINITION 27 | * ============================== */ 28 | 29 | var Button = function (element, options) { 30 | this.$element = $(element) 31 | this.options = $.extend({}, $.fn.button.defaults, options) 32 | } 33 | 34 | Button.prototype.setState = function (state) { 35 | var d = 'disabled' 36 | , $el = this.$element 37 | , data = $el.data() 38 | , val = $el.is('input') ? 'val' : 'html' 39 | 40 | state = state + 'Text' 41 | data.resetText || $el.data('resetText', $el[val]()) 42 | 43 | $el[val](data[state] || this.options[state]) 44 | 45 | // push to event loop to allow forms to submit 46 | setTimeout(function () { 47 | state == 'loadingText' ? 48 | $el.addClass(d).attr(d, d) : 49 | $el.removeClass(d).removeAttr(d) 50 | }, 0) 51 | } 52 | 53 | Button.prototype.toggle = function () { 54 | var $parent = this.$element.closest('[data-toggle="buttons-radio"]') 55 | 56 | $parent && $parent 57 | .find('.active') 58 | .removeClass('active') 59 | 60 | this.$element.toggleClass('active') 61 | } 62 | 63 | 64 | /* BUTTON PLUGIN DEFINITION 65 | * ======================== */ 66 | 67 | $.fn.button = function (option) { 68 | return this.each(function () { 69 | var $this = $(this) 70 | , data = $this.data('button') 71 | , options = typeof option == 'object' && option 72 | if (!data) $this.data('button', (data = new Button(this, options))) 73 | if (option == 'toggle') data.toggle() 74 | else if (option) data.setState(option) 75 | }) 76 | } 77 | 78 | $.fn.button.defaults = { 79 | loadingText: 'loading...' 80 | } 81 | 82 | $.fn.button.Constructor = Button 83 | 84 | 85 | /* BUTTON DATA-API 86 | * =============== */ 87 | 88 | $(function () { 89 | $('body').on('click.button.data-api', '[data-toggle^=button]', function ( e ) { 90 | var $btn = $(e.target) 91 | if (!$btn.hasClass('btn')) $btn = $btn.closest('.btn') 92 | $btn.button('toggle') 93 | }) 94 | }) 95 | 96 | }(window.jQuery); -------------------------------------------------------------------------------- /app/assets/js/bootstrap/bootstrap-carousel.js: -------------------------------------------------------------------------------- 1 | /* ========================================================== 2 | * bootstrap-carousel.js v2.1.1 3 | * http://twitter.github.com/bootstrap/javascript.html#carousel 4 | * ========================================================== 5 | * Copyright 2012 Twitter, Inc. 6 | * 7 | * Licensed under the Apache License, Version 2.0 (the "License"); 8 | * you may not use this file except in compliance with the License. 9 | * You may obtain a copy of the License at 10 | * 11 | * http://www.apache.org/licenses/LICENSE-2.0 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | * ========================================================== */ 19 | 20 | 21 | !function ($) { 22 | 23 | "use strict"; // jshint ;_; 24 | 25 | 26 | /* CAROUSEL CLASS DEFINITION 27 | * ========================= */ 28 | 29 | var Carousel = function (element, options) { 30 | this.$element = $(element) 31 | this.options = options 32 | this.options.slide && this.slide(this.options.slide) 33 | this.options.pause == 'hover' && this.$element 34 | .on('mouseenter', $.proxy(this.pause, this)) 35 | .on('mouseleave', $.proxy(this.cycle, this)) 36 | } 37 | 38 | Carousel.prototype = { 39 | 40 | cycle: function (e) { 41 | if (!e) this.paused = false 42 | this.options.interval 43 | && !this.paused 44 | && (this.interval = setInterval($.proxy(this.next, this), this.options.interval)) 45 | return this 46 | } 47 | 48 | , to: function (pos) { 49 | var $active = this.$element.find('.item.active') 50 | , children = $active.parent().children() 51 | , activePos = children.index($active) 52 | , that = this 53 | 54 | if (pos > (children.length - 1) || pos < 0) return 55 | 56 | if (this.sliding) { 57 | return this.$element.one('slid', function () { 58 | that.to(pos) 59 | }) 60 | } 61 | 62 | if (activePos == pos) { 63 | return this.pause().cycle() 64 | } 65 | 66 | return this.slide(pos > activePos ? 'next' : 'prev', $(children[pos])) 67 | } 68 | 69 | , pause: function (e) { 70 | if (!e) this.paused = true 71 | if (this.$element.find('.next, .prev').length && $.support.transition.end) { 72 | this.$element.trigger($.support.transition.end) 73 | this.cycle() 74 | } 75 | clearInterval(this.interval) 76 | this.interval = null 77 | return this 78 | } 79 | 80 | , next: function () { 81 | if (this.sliding) return 82 | return this.slide('next') 83 | } 84 | 85 | , prev: function () { 86 | if (this.sliding) return 87 | return this.slide('prev') 88 | } 89 | 90 | , slide: function (type, next) { 91 | var $active = this.$element.find('.item.active') 92 | , $next = next || $active[type]() 93 | , isCycling = this.interval 94 | , direction = type == 'next' ? 'left' : 'right' 95 | , fallback = type == 'next' ? 'first' : 'last' 96 | , that = this 97 | , e = $.Event('slide', { 98 | relatedTarget: $next[0] 99 | }) 100 | 101 | this.sliding = true 102 | 103 | isCycling && this.pause() 104 | 105 | $next = $next.length ? $next : this.$element.find('.item')[fallback]() 106 | 107 | if ($next.hasClass('active')) return 108 | 109 | if ($.support.transition && this.$element.hasClass('slide')) { 110 | this.$element.trigger(e) 111 | if (e.isDefaultPrevented()) return 112 | $next.addClass(type) 113 | $next[0].offsetWidth // force reflow 114 | $active.addClass(direction) 115 | $next.addClass(direction) 116 | this.$element.one($.support.transition.end, function () { 117 | $next.removeClass([type, direction].join(' ')).addClass('active') 118 | $active.removeClass(['active', direction].join(' ')) 119 | that.sliding = false 120 | setTimeout(function () { that.$element.trigger('slid') }, 0) 121 | }) 122 | } else { 123 | this.$element.trigger(e) 124 | if (e.isDefaultPrevented()) return 125 | $active.removeClass('active') 126 | $next.addClass('active') 127 | this.sliding = false 128 | this.$element.trigger('slid') 129 | } 130 | 131 | isCycling && this.cycle() 132 | 133 | return this 134 | } 135 | 136 | } 137 | 138 | 139 | /* CAROUSEL PLUGIN DEFINITION 140 | * ========================== */ 141 | 142 | $.fn.carousel = function (option) { 143 | return this.each(function () { 144 | var $this = $(this) 145 | , data = $this.data('carousel') 146 | , options = $.extend({}, $.fn.carousel.defaults, typeof option == 'object' && option) 147 | , action = typeof option == 'string' ? option : options.slide 148 | if (!data) $this.data('carousel', (data = new Carousel(this, options))) 149 | if (typeof option == 'number') data.to(option) 150 | else if (action) data[action]() 151 | else if (options.interval) data.cycle() 152 | }) 153 | } 154 | 155 | $.fn.carousel.defaults = { 156 | interval: 5000 157 | , pause: 'hover' 158 | } 159 | 160 | $.fn.carousel.Constructor = Carousel 161 | 162 | 163 | /* CAROUSEL DATA-API 164 | * ================= */ 165 | 166 | $(function () { 167 | $('body').on('click.carousel.data-api', '[data-slide]', function ( e ) { 168 | var $this = $(this), href 169 | , $target = $($this.attr('data-target') || (href = $this.attr('href')) && href.replace(/.*(?=#[^\s]+$)/, '')) //strip for ie7 170 | , options = !$target.data('modal') && $.extend({}, $target.data(), $this.data()) 171 | $target.carousel(options) 172 | e.preventDefault() 173 | }) 174 | }) 175 | 176 | }(window.jQuery); -------------------------------------------------------------------------------- /app/assets/js/bootstrap/bootstrap-collapse.js: -------------------------------------------------------------------------------- 1 | /* ============================================================= 2 | * bootstrap-collapse.js v2.1.1 3 | * http://twitter.github.com/bootstrap/javascript.html#collapse 4 | * ============================================================= 5 | * Copyright 2012 Twitter, Inc. 6 | * 7 | * Licensed under the Apache License, Version 2.0 (the "License"); 8 | * you may not use this file except in compliance with the License. 9 | * You may obtain a copy of the License at 10 | * 11 | * http://www.apache.org/licenses/LICENSE-2.0 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | * ============================================================ */ 19 | 20 | 21 | !function ($) { 22 | 23 | "use strict"; // jshint ;_; 24 | 25 | 26 | /* COLLAPSE PUBLIC CLASS DEFINITION 27 | * ================================ */ 28 | 29 | var Collapse = function (element, options) { 30 | this.$element = $(element) 31 | this.options = $.extend({}, $.fn.collapse.defaults, options) 32 | 33 | if (this.options.parent) { 34 | this.$parent = $(this.options.parent) 35 | } 36 | 37 | this.options.toggle && this.toggle() 38 | } 39 | 40 | Collapse.prototype = { 41 | 42 | constructor: Collapse 43 | 44 | , dimension: function () { 45 | var hasWidth = this.$element.hasClass('width') 46 | return hasWidth ? 'width' : 'height' 47 | } 48 | 49 | , show: function () { 50 | var dimension 51 | , scroll 52 | , actives 53 | , hasData 54 | 55 | if (this.transitioning) return 56 | 57 | dimension = this.dimension() 58 | scroll = $.camelCase(['scroll', dimension].join('-')) 59 | actives = this.$parent && this.$parent.find('> .accordion-group > .in') 60 | 61 | if (actives && actives.length) { 62 | hasData = actives.data('collapse') 63 | if (hasData && hasData.transitioning) return 64 | actives.collapse('hide') 65 | hasData || actives.data('collapse', null) 66 | } 67 | 68 | this.$element[dimension](0) 69 | this.transition('addClass', $.Event('show'), 'shown') 70 | $.support.transition && this.$element[dimension](this.$element[0][scroll]) 71 | } 72 | 73 | , hide: function () { 74 | var dimension 75 | if (this.transitioning) return 76 | dimension = this.dimension() 77 | this.reset(this.$element[dimension]()) 78 | this.transition('removeClass', $.Event('hide'), 'hidden') 79 | this.$element[dimension](0) 80 | } 81 | 82 | , reset: function (size) { 83 | var dimension = this.dimension() 84 | 85 | this.$element 86 | .removeClass('collapse') 87 | [dimension](size || 'auto') 88 | [0].offsetWidth 89 | 90 | this.$element[size !== null ? 'addClass' : 'removeClass']('collapse') 91 | 92 | return this 93 | } 94 | 95 | , transition: function (method, startEvent, completeEvent) { 96 | var that = this 97 | , complete = function () { 98 | if (startEvent.type == 'show') that.reset() 99 | that.transitioning = 0 100 | that.$element.trigger(completeEvent) 101 | } 102 | 103 | this.$element.trigger(startEvent) 104 | 105 | if (startEvent.isDefaultPrevented()) return 106 | 107 | this.transitioning = 1 108 | 109 | this.$element[method]('in') 110 | 111 | $.support.transition && this.$element.hasClass('collapse') ? 112 | this.$element.one($.support.transition.end, complete) : 113 | complete() 114 | } 115 | 116 | , toggle: function () { 117 | this[this.$element.hasClass('in') ? 'hide' : 'show']() 118 | } 119 | 120 | } 121 | 122 | 123 | /* COLLAPSIBLE PLUGIN DEFINITION 124 | * ============================== */ 125 | 126 | $.fn.collapse = function (option) { 127 | return this.each(function () { 128 | var $this = $(this) 129 | , data = $this.data('collapse') 130 | , options = typeof option == 'object' && option 131 | if (!data) $this.data('collapse', (data = new Collapse(this, options))) 132 | if (typeof option == 'string') data[option]() 133 | }) 134 | } 135 | 136 | $.fn.collapse.defaults = { 137 | toggle: true 138 | } 139 | 140 | $.fn.collapse.Constructor = Collapse 141 | 142 | 143 | /* COLLAPSIBLE DATA-API 144 | * ==================== */ 145 | 146 | $(function () { 147 | $('body').on('click.collapse.data-api', '[data-toggle=collapse]', function (e) { 148 | var $this = $(this), href 149 | , target = $this.attr('data-target') 150 | || e.preventDefault() 151 | || (href = $this.attr('href')) && href.replace(/.*(?=#[^\s]+$)/, '') //strip for ie7 152 | , option = $(target).data('collapse') ? 'toggle' : $this.data() 153 | $this[$(target).hasClass('in') ? 'addClass' : 'removeClass']('collapsed') 154 | $(target).collapse(option) 155 | }) 156 | }) 157 | 158 | }(window.jQuery); -------------------------------------------------------------------------------- /app/assets/js/bootstrap/bootstrap-dropdown.js: -------------------------------------------------------------------------------- 1 | /* ============================================================ 2 | * bootstrap-dropdown.js v2.1.1 3 | * http://twitter.github.com/bootstrap/javascript.html#dropdowns 4 | * ============================================================ 5 | * Copyright 2012 Twitter, Inc. 6 | * 7 | * Licensed under the Apache License, Version 2.0 (the "License"); 8 | * you may not use this file except in compliance with the License. 9 | * You may obtain a copy of the License at 10 | * 11 | * http://www.apache.org/licenses/LICENSE-2.0 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | * ============================================================ */ 19 | 20 | 21 | !function ($) { 22 | 23 | "use strict"; // jshint ;_; 24 | 25 | 26 | /* DROPDOWN CLASS DEFINITION 27 | * ========================= */ 28 | 29 | var toggle = '[data-toggle=dropdown]' 30 | , Dropdown = function (element) { 31 | var $el = $(element).on('click.dropdown.data-api', this.toggle) 32 | $('html').on('click.dropdown.data-api', function () { 33 | $el.parent().removeClass('open') 34 | }) 35 | } 36 | 37 | Dropdown.prototype = { 38 | 39 | constructor: Dropdown 40 | 41 | , toggle: function (e) { 42 | var $this = $(this) 43 | , $parent 44 | , isActive 45 | 46 | if ($this.is('.disabled, :disabled')) return 47 | 48 | $parent = getParent($this) 49 | 50 | isActive = $parent.hasClass('open') 51 | 52 | clearMenus() 53 | 54 | if (!isActive) { 55 | $parent.toggleClass('open') 56 | $this.focus() 57 | } 58 | 59 | return false 60 | } 61 | 62 | , keydown: function (e) { 63 | var $this 64 | , $items 65 | , $active 66 | , $parent 67 | , isActive 68 | , index 69 | 70 | if (!/(38|40|27)/.test(e.keyCode)) return 71 | 72 | $this = $(this) 73 | 74 | e.preventDefault() 75 | e.stopPropagation() 76 | 77 | if ($this.is('.disabled, :disabled')) return 78 | 79 | $parent = getParent($this) 80 | 81 | isActive = $parent.hasClass('open') 82 | 83 | if (!isActive || (isActive && e.keyCode == 27)) return $this.click() 84 | 85 | $items = $('[role=menu] li:not(.divider) a', $parent) 86 | 87 | if (!$items.length) return 88 | 89 | index = $items.index($items.filter(':focus')) 90 | 91 | if (e.keyCode == 38 && index > 0) index-- // up 92 | if (e.keyCode == 40 && index < $items.length - 1) index++ // down 93 | if (!~index) index = 0 94 | 95 | $items 96 | .eq(index) 97 | .focus() 98 | } 99 | 100 | } 101 | 102 | function clearMenus() { 103 | getParent($(toggle)) 104 | .removeClass('open') 105 | } 106 | 107 | function getParent($this) { 108 | var selector = $this.attr('data-target') 109 | , $parent 110 | 111 | if (!selector) { 112 | selector = $this.attr('href') 113 | selector = selector && /#/.test(selector) && selector.replace(/.*(?=#[^\s]*$)/, '') //strip for ie7 114 | } 115 | 116 | $parent = $(selector) 117 | $parent.length || ($parent = $this.parent()) 118 | 119 | return $parent 120 | } 121 | 122 | 123 | /* DROPDOWN PLUGIN DEFINITION 124 | * ========================== */ 125 | 126 | $.fn.dropdown = function (option) { 127 | return this.each(function () { 128 | var $this = $(this) 129 | , data = $this.data('dropdown') 130 | if (!data) $this.data('dropdown', (data = new Dropdown(this))) 131 | if (typeof option == 'string') data[option].call($this) 132 | }) 133 | } 134 | 135 | $.fn.dropdown.Constructor = Dropdown 136 | 137 | 138 | /* APPLY TO STANDARD DROPDOWN ELEMENTS 139 | * =================================== */ 140 | 141 | $(function () { 142 | $('html') 143 | .on('click.dropdown.data-api touchstart.dropdown.data-api', clearMenus) 144 | $('body') 145 | .on('click.dropdown touchstart.dropdown.data-api', '.dropdown form', function (e) { e.stopPropagation() }) 146 | .on('click.dropdown.data-api touchstart.dropdown.data-api' , toggle, Dropdown.prototype.toggle) 147 | .on('keydown.dropdown.data-api touchstart.dropdown.data-api', toggle + ', [role=menu]' , Dropdown.prototype.keydown) 148 | }) 149 | 150 | }(window.jQuery); -------------------------------------------------------------------------------- /app/assets/js/bootstrap/bootstrap-modal.js: -------------------------------------------------------------------------------- 1 | /* ========================================================= 2 | * bootstrap-modal.js v2.1.1 3 | * http://twitter.github.com/bootstrap/javascript.html#modals 4 | * ========================================================= 5 | * Copyright 2012 Twitter, Inc. 6 | * 7 | * Licensed under the Apache License, Version 2.0 (the "License"); 8 | * you may not use this file except in compliance with the License. 9 | * You may obtain a copy of the License at 10 | * 11 | * http://www.apache.org/licenses/LICENSE-2.0 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | * ========================================================= */ 19 | 20 | 21 | !function ($) { 22 | 23 | "use strict"; // jshint ;_; 24 | 25 | 26 | /* MODAL CLASS DEFINITION 27 | * ====================== */ 28 | 29 | var Modal = function (element, options) { 30 | this.options = options 31 | this.$element = $(element) 32 | .delegate('[data-dismiss="modal"]', 'click.dismiss.modal', $.proxy(this.hide, this)) 33 | this.options.remote && this.$element.find('.modal-body').load(this.options.remote) 34 | } 35 | 36 | Modal.prototype = { 37 | 38 | constructor: Modal 39 | 40 | , toggle: function () { 41 | return this[!this.isShown ? 'show' : 'hide']() 42 | } 43 | 44 | , show: function () { 45 | var that = this 46 | , e = $.Event('show') 47 | 48 | this.$element.trigger(e) 49 | 50 | if (this.isShown || e.isDefaultPrevented()) return 51 | 52 | $('body').addClass('modal-open') 53 | 54 | this.isShown = true 55 | 56 | this.escape() 57 | 58 | this.backdrop(function () { 59 | var transition = $.support.transition && that.$element.hasClass('fade') 60 | 61 | if (!that.$element.parent().length) { 62 | that.$element.appendTo(document.body) //don't move modals dom position 63 | } 64 | 65 | that.$element 66 | .show() 67 | 68 | if (transition) { 69 | that.$element[0].offsetWidth // force reflow 70 | } 71 | 72 | that.$element 73 | .addClass('in') 74 | .attr('aria-hidden', false) 75 | .focus() 76 | 77 | that.enforceFocus() 78 | 79 | transition ? 80 | that.$element.one($.support.transition.end, function () { that.$element.trigger('shown') }) : 81 | that.$element.trigger('shown') 82 | 83 | }) 84 | } 85 | 86 | , hide: function (e) { 87 | e && e.preventDefault() 88 | 89 | var that = this 90 | 91 | e = $.Event('hide') 92 | 93 | this.$element.trigger(e) 94 | 95 | if (!this.isShown || e.isDefaultPrevented()) return 96 | 97 | this.isShown = false 98 | 99 | $('body').removeClass('modal-open') 100 | 101 | this.escape() 102 | 103 | $(document).off('focusin.modal') 104 | 105 | this.$element 106 | .removeClass('in') 107 | .attr('aria-hidden', true) 108 | 109 | $.support.transition && this.$element.hasClass('fade') ? 110 | this.hideWithTransition() : 111 | this.hideModal() 112 | } 113 | 114 | , enforceFocus: function () { 115 | var that = this 116 | $(document).on('focusin.modal', function (e) { 117 | if (that.$element[0] !== e.target && !that.$element.has(e.target).length) { 118 | that.$element.focus() 119 | } 120 | }) 121 | } 122 | 123 | , escape: function () { 124 | var that = this 125 | if (this.isShown && this.options.keyboard) { 126 | this.$element.on('keyup.dismiss.modal', function ( e ) { 127 | e.which == 27 && that.hide() 128 | }) 129 | } else if (!this.isShown) { 130 | this.$element.off('keyup.dismiss.modal') 131 | } 132 | } 133 | 134 | , hideWithTransition: function () { 135 | var that = this 136 | , timeout = setTimeout(function () { 137 | that.$element.off($.support.transition.end) 138 | that.hideModal() 139 | }, 500) 140 | 141 | this.$element.one($.support.transition.end, function () { 142 | clearTimeout(timeout) 143 | that.hideModal() 144 | }) 145 | } 146 | 147 | , hideModal: function (that) { 148 | this.$element 149 | .hide() 150 | .trigger('hidden') 151 | 152 | this.backdrop() 153 | } 154 | 155 | , removeBackdrop: function () { 156 | this.$backdrop.remove() 157 | this.$backdrop = null 158 | } 159 | 160 | , backdrop: function (callback) { 161 | var that = this 162 | , animate = this.$element.hasClass('fade') ? 'fade' : '' 163 | 164 | if (this.isShown && this.options.backdrop) { 165 | var doAnimate = $.support.transition && animate 166 | 167 | this.$backdrop = $('
  • ' 282 | , minLength: 1 283 | } 284 | 285 | $.fn.typeahead.Constructor = Typeahead 286 | 287 | 288 | /* TYPEAHEAD DATA-API 289 | * ================== */ 290 | 291 | $(function () { 292 | $('body').on('focus.typeahead.data-api', '[data-provide="typeahead"]', function (e) { 293 | var $this = $(this) 294 | if ($this.data('typeahead')) return 295 | e.preventDefault() 296 | $this.typeahead($this.data()) 297 | }) 298 | }) 299 | 300 | }(window.jQuery); 301 | -------------------------------------------------------------------------------- /app/assets/js/home.js: -------------------------------------------------------------------------------- 1 | 2 | $(document).ready(function() { 3 | $("#go").submit(function() { 4 | var val = $("#topic").val() 5 | if(val.length > 0) { 6 | window.location = "/topics/" + val; 7 | } 8 | return false; 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /app/assets/js/html5-shiv.js: -------------------------------------------------------------------------------- 1 | /*! HTML5 Shiv vpre3.6 | @afarkas @jdalton @jon_neal @rem | MIT/GPL2 Licensed 2 | Uncompressed source: https://github.com/aFarkas/html5shiv */ 3 | (function(a,b){function h(a,b){var c=a.createElement("p"),d=a.getElementsByTagName("head")[0]||a.documentElement;return c.innerHTML="x",d.insertBefore(c.lastChild,d.firstChild)}function i(){var a=l.elements;return typeof a=="string"?a.split(" "):a}function j(a){var b={},c=a.createElement,f=a.createDocumentFragment,g=f();a.createElement=function(a){if(!l.shivMethods)return c(a);var f;return b[a]?f=b[a].cloneNode():e.test(a)?f=(b[a]=c(a)).cloneNode():f=c(a),f.canHaveChildren&&!d.test(a)?g.appendChild(f):f},a.createDocumentFragment=Function("h,f","return function(){var n=f.cloneNode(),c=n.createElement;h.shivMethods&&("+i().join().replace(/\w+/g,function(a){return c(a),g.createElement(a),'c("'+a+'")'})+");return n}")(l,g)}function k(a){var b;return a.documentShived?a:(l.shivCSS&&!f&&(b=!!h(a,"article,aside,details,figcaption,figure,footer,header,hgroup,nav,section{display:block}audio{display:none}canvas,video{display:inline-block;*display:inline;*zoom:1}[hidden]{display:none}audio[controls]{display:inline-block;*display:inline;*zoom:1}mark{background:#FF0;color:#000}")),g||(b=!j(a)),b&&(a.documentShived=b),a)}var c=a.html5||{},d=/^<|^(?:button|form|map|select|textarea|object|iframe|option|optgroup)$/i,e=/^<|^(?:a|b|button|code|div|fieldset|form|h1|h2|h3|h4|h5|h6|i|iframe|img|input|label|li|link|ol|option|p|param|q|script|select|span|strong|style|table|tbody|td|textarea|tfoot|th|thead|tr|ul)$/i,f,g;(function(){var c=b.createElement("a");c.innerHTML="",f="hidden"in c,f&&typeof injectElementWithStyles=="function"&&injectElementWithStyles("#modernizr{}",function(b){b.hidden=!0,f=(a.getComputedStyle?getComputedStyle(b,null):b.currentStyle).display=="none"}),g=c.childNodes.length==1||function(){try{b.createElement("a")}catch(a){return!0}var c=b.createDocumentFragment();return typeof c.cloneNode=="undefined"||typeof c.createDocumentFragment=="undefined"||typeof c.createElement=="undefined"}()})();var l={elements:c.elements||"abbr article aside audio bdi canvas data datalist details figcaption figure footer header hgroup mark meter nav output progress section summary time video",shivCSS:c.shivCSS!==!1,shivMethods:c.shivMethods!==!1,type:"default",shivDocument:k};a.html5=l,k(b)})(this,document) -------------------------------------------------------------------------------- /app/assets/js/topics.js: -------------------------------------------------------------------------------- 1 | //= require ./jsonlint.js 2 | 3 | function setupTopic(topic) { 4 | var url = window.location.protocol + '//' + window.location.hostname; 5 | if (window.location.port) { 6 | url += ':' + window.location.port; 7 | } 8 | var socket = io.connect(url) 9 | 10 | var textarea = $("#payload"); 11 | var textarea_error = $("#payload-error"); 12 | var textarea_field = $("#payload-field"); 13 | var update = $("#update"); 14 | var edit = $("#edit"); 15 | var cancel = $("#cancel"); 16 | var validate = $("#validate"); 17 | 18 | update.hide(); 19 | cancel.hide(); 20 | 21 | var last_data = null; 22 | 23 | update_content = function(data) { 24 | data = { payload: data } 25 | last_data = data; 26 | if(textarea.attr("readonly")) { 27 | if(typeof data.payload === "string") { 28 | textarea.val(data.payload); 29 | validate.removeAttr("checked"); 30 | } else { 31 | textarea.val(JSON.stringify(data.payload, null, 4)); 32 | validate.attr("checked", true); 33 | last_data.json = true; 34 | } 35 | } 36 | }; 37 | 38 | socket.on("connect", function(data) { 39 | socket.emit('subscribe', topic); 40 | }); 41 | 42 | socket.on("/topics/" + topic, function(data) { 43 | update_content(data); 44 | }); 45 | 46 | update.click(function() { 47 | var val = textarea.val(); 48 | var settings = { 49 | url: "/topics/"+ topic, 50 | type: "PUT" 51 | } 52 | if(validate.attr("checked")) { 53 | try { 54 | val = JSON.parse(val); 55 | if(typeof val == 'string') { 56 | throw "invalid json"; 57 | } 58 | settings.data = JSON.stringify(val); 59 | settings.contentType = "application/json"; 60 | } catch(e) { 61 | textarea.addClass("error"); 62 | textarea_field.addClass("error"); 63 | textarea_error.show(); 64 | return false; 65 | } 66 | } else { 67 | settings.data = { payload: val }; 68 | } 69 | 70 | $.ajax(settings); 71 | textarea.removeClass("error"); 72 | textarea_field.removeClass("error"); 73 | textarea_error.hide(); 74 | 75 | textarea.attr("readonly", true); 76 | validate.attr("disabled", true); 77 | edit.toggle(); 78 | update.toggle(); 79 | cancel.toggle(); 80 | return false; 81 | }); 82 | 83 | edit.click(function() { 84 | textarea.removeAttr("readonly"); 85 | validate.removeAttr("disabled"); 86 | update.toggle(); 87 | cancel.toggle(); 88 | edit.toggle(); 89 | return false; 90 | }); 91 | 92 | cancel.click(function() { 93 | if(last_data.json) { 94 | textarea.val(JSON.stringify(last_data.payload)); 95 | } else { 96 | textarea.val(last_data.payload); 97 | } 98 | validate.attr("checked", last_data.json); 99 | textarea.attr("readonly", true); 100 | validate.attr("disabled", true); 101 | update.toggle(); 102 | cancel.toggle(); 103 | edit.toggle(); 104 | 105 | textarea.removeClass("error"); 106 | textarea_field.removeClass("error"); 107 | textarea_error.hide(); 108 | return false; 109 | }); 110 | } 111 | -------------------------------------------------------------------------------- /app/controllers/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcollina/qest/810f720b61c47fa74cdf7bb2e3ea616c4e548ffd/app/controllers/.gitkeep -------------------------------------------------------------------------------- /app/controllers/home.coffee: -------------------------------------------------------------------------------- 1 | 2 | module.exports = (app) -> 3 | app.get '/', (req, res) -> 4 | req.session.topics ||= [] 5 | res.render 'home.hbs', topics: req.session.topics 6 | -------------------------------------------------------------------------------- /app/controllers/http_api.coffee: -------------------------------------------------------------------------------- 1 | 2 | module.exports = (app) -> 3 | io = app.io 4 | Data = app.models.Data 5 | 6 | app.get (/^\/topics\/(.+)$/), (req, res) -> 7 | topic = req.params[0] 8 | 9 | topics = req.session.topics || [] 10 | index = topics.indexOf(topic) 11 | 12 | if index >= 0 13 | topics = [].concat(topics.splice(0, index), topics.splice(index + 1, req.session.topics.length)) 14 | 15 | topics.push(topic) 16 | topics.pull() if topics.length > 5 17 | 18 | req.session.topics = topics 19 | 20 | Data.find topic, (err, data) -> 21 | 22 | type = req.accepts(['txt', 'json', 'html']) 23 | 24 | if type == "html" 25 | res.render 'topic.hbs', topic: topic 26 | else if err? 27 | res.send 404 28 | else if type == 'json' 29 | res.contentType('json') 30 | try 31 | res.json data.value 32 | catch e 33 | res.json "" + data.value 34 | else if type == 'txt' 35 | res.send data.value 36 | else 37 | res.send 406 38 | 39 | app.put /^\/topics\/(.+)$/, (req, res) -> 40 | topic = req.params[0] 41 | if req.is("json") 42 | payload = req.body 43 | else 44 | payload = req.body.payload 45 | Data.findOrCreate topic, payload 46 | res.send 204 47 | -------------------------------------------------------------------------------- /app/controllers/mqtt_api.coffee: -------------------------------------------------------------------------------- 1 | 2 | module.exports = (app) -> 3 | Data = app.models.Data 4 | 5 | (client) -> 6 | 7 | listeners = {} 8 | 9 | unsubscribeAll = -> 10 | for topic, listener of listeners 11 | Data.unsubscribe(topic, listener) 12 | 13 | client.on 'connect', (packet) -> 14 | client.id = packet.client 15 | client.connack(returnCode: 0) 16 | 17 | client.on 'subscribe', (packet) -> 18 | granted = [] 19 | subscriptions = [] 20 | 21 | for subscription in packet.subscriptions 22 | # '#' is 'match anything to the end of the string' */ 23 | # + is 'match anything but a / until you hit a /' */ 24 | subscriptions.push(subscription.topic.replace("#", "*")) 25 | granted.push 0 26 | 27 | client.suback(messageId: packet.messageId, granted: granted) 28 | 29 | # subscribe for updates 30 | for subscription in subscriptions 31 | (-> 32 | listener = (data) -> 33 | try 34 | if typeof data.value == "string" 35 | value = data.value 36 | else 37 | value = data.jsonValue 38 | client.publish(topic: data.key, payload: value) 39 | catch error 40 | console.log error 41 | client.close() 42 | listeners[subscription] = listener 43 | Data.subscribe(subscription, listener) 44 | 45 | Data.find new RegExp(subscription), (err, data) -> 46 | throw err if err? # the persistance layer is not working properly 47 | listener(data) 48 | )() 49 | 50 | client.on 'publish', (packet) -> 51 | payload = packet.payload 52 | try 53 | payload = JSON.parse(payload) 54 | catch error 55 | # nothing to do 56 | Data.findOrCreate packet.topic, payload 57 | 58 | client.on 'pingreq', (packet) -> 59 | client.pingresp() 60 | 61 | client.on 'disconnect', -> 62 | client.stream.end() 63 | 64 | client.on 'error', (error) -> 65 | console.log error 66 | client.stream.end() 67 | 68 | client.on 'close', (err) -> 69 | unsubscribeAll() 70 | 71 | client.on 'unsubscribe', (packet) -> 72 | client.unsuback(messageId: packet.messageId) 73 | -------------------------------------------------------------------------------- /app/controllers/websocket_api.coffee: -------------------------------------------------------------------------------- 1 | 2 | module.exports = (app) -> 3 | Data = app.models.Data 4 | 5 | app.io.sockets.on 'connection', (socket) -> 6 | 7 | subscriptions = {} 8 | 9 | socket.on 'subscribe', (topic) -> 10 | 11 | subscription = (currentData) -> 12 | socket.emit("/topics/#{topic}", currentData.value) 13 | 14 | subscriptions[topic] = subscription 15 | 16 | Data.subscribe topic, subscription 17 | 18 | Data.find topic, (err, data) -> 19 | subscription(data) if data?.value? 20 | 21 | socket.on 'disconnect', -> 22 | for topic, listener of subscriptions 23 | Data.unsubscribe(topic, listener) 24 | -------------------------------------------------------------------------------- /app/helpers/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcollina/qest/810f720b61c47fa74cdf7bb2e3ea616c4e548ffd/app/helpers/.gitkeep -------------------------------------------------------------------------------- /app/helpers/asset_pipeline.coffee: -------------------------------------------------------------------------------- 1 | hbs = require 'hbs' 2 | path = require 'path' 3 | Mincer = require('mincer') 4 | 5 | module.exports = (app) -> 6 | environment = new Mincer.Environment() 7 | environment.appendPath('app/assets/js') 8 | environment.appendPath('app/assets/css') 9 | 10 | app.use("/assets", Mincer.createServer(environment)) 11 | 12 | # dummy helper that injects extension 13 | rewrite_extension = (source, ext) -> 14 | source_ext = path.extname(source) 15 | if (source_ext == ext) 16 | source 17 | else 18 | (source + ext) 19 | 20 | # returns a list of asset paths 21 | find_asset_paths = (logicalPath, ext) -> 22 | asset = environment.findAsset(logicalPath) 23 | paths = [] 24 | 25 | if (!asset) 26 | return null 27 | 28 | if ('production' != process.env.NODE_ENV && asset.isCompiled) 29 | asset.toArray().forEach (dep) -> 30 | paths.push('/assets/' + rewrite_extension(dep.logicalPath, ext) + '?body=1') 31 | else 32 | paths.push('/assets/' + rewrite_extension(asset.digestPath, ext)) 33 | 34 | return paths 35 | 36 | hbs.registerHelper 'js', (logicalPath) -> 37 | paths = find_asset_paths(logicalPath, ".js") 38 | 39 | if (!paths) 40 | # this will help us notify that given logicalPath is not found 41 | # without "breaking" view renderer 42 | return new hbs.SafeString('') 45 | 46 | result = paths.map (path) -> 47 | '' 48 | new hbs.SafeString(result.join("\n")) 49 | 50 | hbs.registerHelper 'css', (logicalPath) -> 51 | paths = find_asset_paths(logicalPath, ".css") 52 | 53 | if (!paths) 54 | # this will help us notify that given logicalPath is not found 55 | # without "breaking" view renderer 56 | return new hbs.SafeString('') 59 | 60 | result = paths.map (path) -> 61 | '' 62 | new hbs.SafeString(result.join("\n")) 63 | -------------------------------------------------------------------------------- /app/helpers/json.coffee: -------------------------------------------------------------------------------- 1 | 2 | 3 | hbs = require 'hbs' 4 | 5 | module.exports = (app) -> 6 | hbs.registerHelper 'json', (context) -> 7 | new hbs.SafeString(JSON.stringify(context)) 8 | -------------------------------------------------------------------------------- /app/helpers/markdown.coffee: -------------------------------------------------------------------------------- 1 | 2 | hbs = require 'hbs' 3 | 4 | module.exports = (app) -> 5 | hbs.registerHelper 'markdown', (options) -> 6 | input = options.fn(@) 7 | result = require( "markdown" ).markdown.toHTML(input) 8 | return result 9 | -------------------------------------------------------------------------------- /app/helpers/notest.coffee: -------------------------------------------------------------------------------- 1 | 2 | hbs = require 'hbs' 3 | 4 | module.exports = (app) -> 5 | hbs.registerHelper 'notest', (options) -> 6 | if process.env.NODE_ENV != "test" 7 | input = options.fn(@) 8 | return input 9 | else 10 | return "" 11 | -------------------------------------------------------------------------------- /app/models/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcollina/qest/810f720b61c47fa74cdf7bb2e3ea616c4e548ffd/app/models/.gitkeep -------------------------------------------------------------------------------- /app/models/data.coffee: -------------------------------------------------------------------------------- 1 | 2 | EventEmitter = require('events').EventEmitter 3 | 4 | globalEventEmitter = new EventEmitter() 5 | globalEventEmitter.setMaxListeners(0) 6 | events = {} 7 | 8 | KEYS_SET_NAME = 'topics' 9 | 10 | module.exports = (app) -> 11 | buildKey = (key) -> 12 | "topic:#{key}" 13 | 14 | class Data 15 | 16 | constructor: (@key, @value) -> 17 | @value ||= null 18 | 19 | Object.defineProperty @prototype, 'key', 20 | enumerable: true 21 | configurable: false 22 | get: -> @_key 23 | set: (key) -> 24 | @redisKey = buildKey(key) 25 | @_key = key 26 | 27 | Object.defineProperty @prototype, 'jsonValue', 28 | configurable: false 29 | enumerable: true 30 | get: -> 31 | JSON.stringify(@value) 32 | 33 | set: (val) -> 34 | @value = JSON.parse(val) 35 | 36 | save: (callback) -> 37 | app.redis.client.set @redisKey, @jsonValue, (err) => 38 | app.ascoltatore.publish @key, @value, => 39 | callback(err, @) if callback? 40 | 41 | app.redis.client.sadd KEYS_SET_NAME, @key 42 | 43 | Data.find = (pattern, callback) -> 44 | 45 | foundRecord = (key) -> 46 | app.redis.client.get buildKey(key), (err, value) -> 47 | if err 48 | callback(err) if callback? 49 | return 50 | 51 | unless value? 52 | callback("Record not found") if callback? 53 | return 54 | 55 | callback(null, Data.fromRedis(key, value)) if callback? 56 | 57 | if pattern.constructor != RegExp 58 | foundRecord(pattern) 59 | else 60 | app.redis.client.smembers KEYS_SET_NAME, (err, topics) -> 61 | for topic in topics 62 | foundRecord(topic) if pattern.test(topic) 63 | 64 | Data 65 | 66 | Data.findOrCreate = -> 67 | args = Array.prototype.slice.call arguments 68 | 69 | key = args.shift() # first arg shifted out 70 | arg = args.shift() # second arg popped out 71 | 72 | if typeof arg == 'function' 73 | # if the second arg is a function, 74 | # then there is no third arg 75 | callback = arg 76 | else 77 | # if the second arg is not a function 78 | # then it's the value, and the third is 79 | # the callback 80 | value = arg 81 | callback = args.shift() 82 | 83 | # FIXME this is not atomic, is it a problem? 84 | app.redis.client.get buildKey(key), (err, oldValue) -> 85 | data = Data.fromRedis(key, oldValue) 86 | data.value = value if value? 87 | data.save(callback) 88 | 89 | Data 90 | 91 | Data.fromRedis = (topic, value) -> 92 | data = new Data(topic) 93 | data.jsonValue = value 94 | data 95 | 96 | Data.subscribe = (topic, callback) -> 97 | callback._subscriber = (actualTopic, value) -> 98 | callback(new Data(actualTopic, value)) 99 | app.ascoltatore.subscribe topic, callback._subscriber 100 | @ 101 | 102 | Data.unsubscribe = (topic, callback) -> 103 | app.ascoltatore.unsubscribe topic, callback._subscriber 104 | @ 105 | 106 | Data 107 | -------------------------------------------------------------------------------- /app/views/home.hbs: -------------------------------------------------------------------------------- 1 | 2 | 3 |
    4 |
    5 |

    6 |
    7 |

    QEST

    8 |

    The stargate of devices

    9 |
    10 |
    11 |
    12 |

    Open your topic

    13 |
    14 | 15 | 16 |
    17 |
    18 |
    19 |
    20 |
    21 |

    22 |
    23 |
    24 | 25 |
    26 |
    27 |
    28 |

    Your latest topics

    29 | {{#if topics.length }} 30 |
      31 | {{#each topics}} 32 |
    • {{this}}
    • 33 | {{/each}} 34 |
    35 | {{else}} 36 |

    37 | No topics found, let‘s open a new one! 38 |

    39 | {{/if}} 40 |
    41 |
    42 |
    43 | 44 |
    45 |
    46 |

    Building

    47 | {{#markdown}} 48 | QEST is a stargate between the universe of devices which speak [MQTT](http://mqtt.org), and the universe of apps which 49 | speak [HTTP](http://en.wikipedia.org/wiki/HTTP) and [REST](http://en.wikipedia.org/wiki/REST). 50 | In this way you don't have to deal any custom protocol, you just GET and PUT the topic URI, like these: 51 | 52 | $ curl -X PUT -d '{ "hello": 555 }' \ 53 | -H "Content-Type: application/json" \ 54 | http://mqtt.matteocollina.com/topics/prova 55 | $ curl http://mqtt.matteocollina.com/topics/prova 56 | { "hello": 555 } 57 | 58 | Let's build cool things with MQTT, REST and Arduino! 59 | {{/markdown}} 60 |
    61 | 62 |
    63 | 64 |

    Dreaming

    65 | {{#markdown}} 66 | Here we are dreaming a Web of Things, where you can reach (and interact) with each of your "real" devices using the web, 67 | as it's the Way everybody interacts with a computer these days. 68 | However it's somewhat hard to build these kind of apps, so researchers have written custom protocols for communicating 69 | with the devices. 70 | 71 | The state-of-the-art protocol for __devices__ is [MQTT](http://mqtt.org), which is standard, free of royalties, and widespread: 72 | there are libraries for all the major platforms. 73 | 74 | The state-of-the-art protocol for __apps__ are [REST](http://en.wikipedia.org/wiki/REST) and [HTTP](http://en.wikipedia.org/wiki/HTTP), 75 | so why can't we bridge them? So QEST was born. 76 | {{/markdown}} 77 |
    78 | 79 |
    80 | 81 |
    82 |

    Mailing list

    83 |
    84 | 85 | 86 |
    87 |
    88 | 89 | 90 |

    Examples:

    91 | {{#markdown}} 92 | * [NetworkButton](https://github.com/mcollina/qest/wiki/Network-Button-Example) 93 | * [NetworkButtonJSON](https://gist.github.com/mcollina/5337389), same as 94 | before, but exchanging JSONs. 95 | {{/markdown}} 96 |
    97 |
    98 | {{ js "home.js" }} 99 | -------------------------------------------------------------------------------- /app/views/layout.hbs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | QEST - The Internet of Things broker that loves devices and developers 5 | {{css "main.css"}} 6 | 9 | {{js "bootstrap.js"}} 10 | 11 | 12 | 13 | 33 | 34 |
    35 | 36 |
    37 | {{{body}}} 38 |
    39 | 40 | 43 | 44 |
    45 | 46 | {{#notest}} 47 | 48 | 68 | 69 | {{/notest}} 70 | 71 | 72 | 73 | -------------------------------------------------------------------------------- /app/views/network_button.hbs: -------------------------------------------------------------------------------- 1 |
    2 |
    3 | {{#markdown}} 4 | NetworkButton Example 5 | ----- 6 | 7 | NetworkButton is a forest of network enabled leds that switch on and off on simultaneosly. 8 | It's also an example with MQTT-REST. 9 | 10 | Let's build your own! 11 | {{/markdown}} 12 | 13 | {{#notest}} 14 | 15 | {{/notest}} 16 |
    17 |
    18 | -------------------------------------------------------------------------------- /app/views/topic.hbs: -------------------------------------------------------------------------------- 1 | 4 |
    5 |
    6 |
    7 |
    8 | 9 |
    10 | 11 | Impossible to parse as JSON 12 |
    13 |
    14 |
    15 | 16 |
    17 | 18 |
    19 |
    20 |
    21 |
    22 | 23 | 24 | 25 |
    26 |
    27 |
    28 |
    29 |
    30 | 31 | 32 | {{ js "topics.js" }} 33 | 36 | -------------------------------------------------------------------------------- /benchmarks/bench_http_mqtt_multiple_pub.coffee: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env coffee 2 | 3 | request = require("request") 4 | Pool = require("./mqtt_client_pool").Pool 5 | 6 | host = "localhost" 7 | mqtt_port = 1883 8 | http_port = 3000 9 | 10 | Benchmark = require 'benchmark' 11 | 12 | pool = new Pool(host, mqtt_port) 13 | 14 | suite = new Benchmark.Suite 15 | 16 | payload = 0 17 | 18 | setup_bench = (suite, number) -> 19 | suite.add("#{number} publishes", (d) -> 20 | payload += 1 21 | publish_count = 0 22 | subscribed_count = 0 23 | topic = "bench/#{number}" 24 | 25 | pool.get (client) -> 26 | 27 | # when we receive an update 28 | client.on 'publish', (packet) -> 29 | 30 | if String(packet.payload) == String(number) 31 | # we unsubscribe from the topic 32 | client.unsubscribe(topic: topic) 33 | client.removeAllListeners('publish') 34 | client.removeAllListeners('suback') 35 | 36 | # we resolve the benchmark as we have received all the updates 37 | d.resolve() 38 | 39 | client.on 'unsuback', -> 40 | # and we put the client back to the pool 41 | pool.release(client) 42 | client.removeAllListeners('unsuback') 43 | 44 | # subscribe to the topic 45 | client.subscribe(topic: topic) 46 | 47 | client.on 'suback', -> 48 | 49 | setup_request = (num) -> 50 | process.nextTick -> 51 | request.put url: "http://#{host}:#{http_port}/topics/#{topic}", json: { payload: num} 52 | 53 | setup_request(num) for num in [0..number] 54 | 55 | , defer: true) 56 | suite 57 | 58 | # setting up the benches 59 | # setup_bench(suite, 1) 60 | # setup_bench(suite, 10) 61 | # setup_bench(suite, 100) 62 | # setup_bench(suite, 1000) 63 | setup_bench(suite, 10000) 64 | 65 | suite.on('cycle', (event) -> 66 | console.log(event.target.name) 67 | console.log(event.target.stats.mean) 68 | console.log("total clients: #{pool.created()}") 69 | ).on('complete', -> 70 | process.exit(0) 71 | ) 72 | 73 | 74 | suite.run(minSamples: 10, delay: 10, async: false, initCount: 1, maxTime: 60) 75 | 76 | # clients = [] 77 | # total = 0 78 | # preload_connections = 15100 79 | # load_cycle = 100 80 | # launched = false 81 | # 82 | # create = -> 83 | # for num in [0...load_cycle] 84 | # pool.get (client) -> 85 | # total += 1 86 | # clients.push(client) 87 | # if total % load_cycle == 0 88 | # if total < preload_connections 89 | # setTimeout(create, 500) 90 | # else if not launched 91 | # launched = true 92 | # console.log "connection pool populated" 93 | # pool.release(client) for client in clients 94 | # suite.run(minSamples: 10, delay: 10, async: false, initCount: 1, maxTime: 60) 95 | # 96 | # console.log "populating connection pool" 97 | # create() 98 | -------------------------------------------------------------------------------- /benchmarks/bench_http_mqtt_multiple_sub.coffee: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env coffee 2 | 3 | request = require("request") 4 | Pool = require("./mqtt_client_pool").Pool 5 | 6 | host = "localhost" 7 | mqtt_port = 1883 8 | http_port = 3000 9 | 10 | Benchmark = require 'benchmark' 11 | 12 | pool = new Pool(host, mqtt_port) 13 | 14 | suite = new Benchmark.Suite 15 | 16 | payload = 0 17 | 18 | setup_listeners = (suite, number) -> 19 | suite.add("#{number} Client", (d) -> 20 | payload += 1 21 | publish_count = 0 22 | subscribed_count = 0 23 | topic = "bench/#{number}" 24 | for num in [0...number] 25 | pool.get (client) -> 26 | 27 | # when we receive an update 28 | client.on 'publish', (packet) -> 29 | 30 | if packet.payload = String(payload) 31 | # we unsubscribe from the topic 32 | client.unsubscribe(topic: topic) 33 | client.removeAllListeners('publish') 34 | client.removeAllListeners('suback') 35 | 36 | # we resolve the benchmark if we have received all the updates 37 | publish_count += 1 38 | if publish_count == number 39 | #console.log "completed run" 40 | d.resolve() 41 | 42 | client.on 'unsuback', -> 43 | # and we put the client back to the pool 44 | pool.release(client) 45 | client.removeAllListeners('unsuback') 46 | 47 | # subscribe to the topic 48 | client.subscribe(topic: topic) 49 | 50 | # when we receive a subscription ack 51 | client.on 'suback', (packet) -> 52 | subscribed_count += 1 53 | 54 | # if we completed the subscriptions 55 | if subscribed_count == number 56 | request.put url: "http://#{host}:#{http_port}/topics/#{topic}", json: { payload: payload } 57 | 58 | , defer: true) 59 | suite 60 | 61 | # setting up the benches 62 | # setup_listeners(suite, 1) 63 | # setup_listeners(suite, 10) 64 | setup_listeners(suite, 100) 65 | # setup_listeners(suite, 1000) 66 | # setup_listeners(suite, 10000) 67 | 68 | suite.on('cycle', (event) -> 69 | console.log(event.target.name) 70 | console.log(event.target.stats.mean) 71 | console.log("total clients: #{pool.created()}") 72 | ).on('complete', -> 73 | process.exit(0) 74 | ) 75 | 76 | 77 | suite.run(minSamples: 10, delay: 10, async: false, initCount: 1, maxTime: 60) 78 | 79 | # clients = [] 80 | # total = 0 81 | # preload_connections = 15100 82 | # load_cycle = 100 83 | # launched = false 84 | # 85 | # create = -> 86 | # for num in [0...load_cycle] 87 | # pool.get (client) -> 88 | # total += 1 89 | # clients.push(client) 90 | # if total % load_cycle == 0 91 | # if total < preload_connections 92 | # setTimeout(create, 500) 93 | # else if not launched 94 | # launched = true 95 | # console.log "connection pool populated" 96 | # pool.release(client) for client in clients 97 | # suite.run(minSamples: 10, delay: 10, async: false, initCount: 1, maxTime: 60) 98 | # 99 | # console.log "populating connection pool" 100 | # create() 101 | -------------------------------------------------------------------------------- /benchmarks/bench_mqtt.coffee: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env coffee 2 | 3 | Pool = require("./mqtt_client_pool").Pool 4 | mqtt_port = 1883 5 | 6 | Benchmark = require 'benchmark' 7 | 8 | pool = new Pool("localhost", mqtt_port) 9 | 10 | suite = new Benchmark.Suite 11 | 12 | payload = 0 13 | 14 | setup_listeners = (suite, number) -> 15 | suite.add("#{number} Client", (d) -> 16 | payload = 1 17 | publish_count = 0 18 | subscribed_count = 0 19 | topic = "bench/#{number}" 20 | for num in [0...number] 21 | pool.get (client) -> 22 | 23 | # subscribe to the topic 24 | client.subscribe(topic: topic) 25 | 26 | # when we receive an update 27 | client.on 'publish', (packet) -> 28 | 29 | if packet.payload = String(payload) 30 | # we unsubscribe from the topic 31 | client.unsubscribe(topic: topic) 32 | client.removeAllListeners('publish') 33 | client.removeAllListeners('suback') 34 | 35 | # we resolve the benchmark if we have received all the updates 36 | publish_count += 1 37 | if publish_count == number 38 | #console.log "completed run" 39 | d.resolve() 40 | 41 | client.on 'unsuback', -> 42 | # and we put the client back to the pool 43 | client.removeAllListeners('unsuback') 44 | pool.release(client) 45 | 46 | # when we receive a subscription ack 47 | client.on 'suback', (packet) -> 48 | subscribed_count += 1 49 | 50 | # console.log "suback #{subscribed_count}" 51 | 52 | # if we completed the subscriptions 53 | if subscribed_count == number 54 | for count in [0...10] 55 | pool.do (pub_client) -> 56 | # we publish a new value on the queue 57 | # we do it ten times, so if one it's rejected 58 | # it's not a problem 59 | pub_client.publish topic: topic, payload: String(payload) 60 | 61 | , defer: true) 62 | suite 63 | 64 | # setting up the benches 65 | setup_listeners(suite, 1) 66 | setup_listeners(suite, 10) 67 | setup_listeners(suite, 100) 68 | setup_listeners(suite, 1000) 69 | # setup_listeners(suite, 10000) 70 | 71 | suite.on('cycle', (event) -> 72 | console.log(event.target.name) 73 | console.log(event.target.stats.mean) 74 | console.log("total clients: #{pool.created()}") 75 | ).on('complete', -> 76 | process.exit(0) 77 | ) 78 | 79 | suite.run(minSamples: 100, maxSamples: 1000, delay: 10, async: false, initCount: 1000, maxTime: 60) 80 | 81 | # clients = [] 82 | # total = 0 83 | # preload_connections = 20001 84 | # load_cycle = 100 85 | # launched = false 86 | # 87 | # create = -> 88 | # for num in [0...load_cycle] 89 | # pool.get (client) -> 90 | # total += 1 91 | # clients.push(client) 92 | # if total % load_cycle == 0 93 | # if total < preload_connections 94 | # setTimeout(create, 500) 95 | # else if not launched 96 | # launched = true 97 | # console.log "connection pool populated" 98 | # pool.release(client) for client in clients 99 | # suite.run(minSamples: 10, delay: 10, async: false, initCount: 1, maxTime: 10) 100 | # 101 | # console.log "populating connection pool" 102 | # create() 103 | -------------------------------------------------------------------------------- /benchmarks/haproxy.cfg: -------------------------------------------------------------------------------- 1 | global 2 | ulimit-n 999999 3 | maxconn 65000 4 | maxpipes 65000 5 | tune.maxaccept 500 6 | spread-checks 5 7 | 8 | defaults 9 | retries 5 10 | 11 | option redispatch 12 | option tcp-smart-connect 13 | option tcpka 14 | 15 | timeout client 5m 16 | timeout queue 5m 17 | timeout server 5m 18 | timeout connect 5m 19 | 20 | listen qest-http :3000 21 | mode http 22 | maxconn 65000 23 | balance roundrobin 24 | 25 | server qest-http1 localhost:8001 check 26 | server qest-http2 localhost:8002 check 27 | server qest-http3 localhost:8003 check 28 | server qest-http4 localhost:8004 check 29 | server qest-http5 localhost:8005 check 30 | 31 | 32 | listen qest-mqtt :1883 33 | mode tcp 34 | maxconn 65000 35 | balance roundrobin 36 | 37 | server qest-mqtt1 localhost:9001 check 38 | server qest-mqtt2 localhost:9002 check 39 | server qest-mqtt4 localhost:9003 check 40 | server qest-mqtt3 localhost:9004 check 41 | server qest-mqtt5 localhost:9005 check 42 | -------------------------------------------------------------------------------- /benchmarks/mqtt_client_pool.coffee: -------------------------------------------------------------------------------- 1 | mqtt = require('mqttjs') 2 | 3 | class Pool 4 | 5 | constructor: (@host, @port) -> 6 | @clients_counter = 0 7 | @clients = [] 8 | @total_errors = 0 9 | 10 | print_total_errors = => 11 | console.log "Current errors: #{@total_errors}" 12 | setTimeout(print_total_errors, 2000) 13 | # print_total_errors() 14 | 15 | created: () -> @clients_counter 16 | 17 | do: (callback) -> 18 | @get (client) => 19 | callback(client) 20 | @release(client) 21 | 22 | release: (client) -> @clients.push(client) 23 | 24 | get: (setupCallback = ->) -> 25 | client = @clients.pop() 26 | 27 | if client? 28 | setupCallback(client) 29 | return @ 30 | 31 | errors = 0 32 | 33 | @clients_counter += 1 34 | client_id = @clients_counter 35 | 36 | create = => 37 | created = false 38 | mqtt.createClient @port, @host, (err, client) => 39 | 40 | if err? 41 | @total_errors += 1 if errors == 0 42 | setTimeout(=> 43 | errors += 1 44 | if errors == 10 45 | console.log "Impossible to connect to the server" 46 | process.exit(1) 47 | # console.log "Connecting error n #{errors}" 48 | # console.log "Reconnecting" 49 | create() 50 | , 100) 51 | return 52 | 53 | # console.log "created #{@clients_counter} with errors #{errors}" if errors > 0 54 | 55 | # console.log "total clients: #{@clients_counter}" 56 | 57 | client.connect client: "mqtt_bench_#{client_id}_#{errors}", keepalive: 500 58 | 59 | client.on 'connack', (packet) => 60 | if packet.returnCode == 0 61 | @total_errors -= 1 if errors > 0 62 | setupCallback(client) 63 | else 64 | console.log('connack error %d', packet.returnCode) 65 | 66 | client.on 'pingreq', (packet) -> 67 | client.pingresp() 68 | 69 | client.on 'close', -> 70 | client.stream.removeAllListeners() 71 | client.stream.destroy() 72 | 73 | create() 74 | @ 75 | 76 | destroyAll: -> 77 | for client in @clients 78 | client.disconnect() 79 | 80 | @clients = [] 81 | 82 | module.exports.Pool = Pool 83 | -------------------------------------------------------------------------------- /features/homepage.feature: -------------------------------------------------------------------------------- 1 | Feature: Home page 2 | As a prospect of QEST 3 | I want to get the home page 4 | 5 | Scenario: QEST name 6 | When I visit "/" 7 | Then I should see "QEST" 8 | 9 | Scenario: QEST title 10 | When I visit "/" 11 | Then I should see the title "QEST - The Internet of Things broker that loves devices and developers" 12 | 13 | -------------------------------------------------------------------------------- /features/http_pub_sub.feature: -------------------------------------------------------------------------------- 1 | Feature: HTTP pub/sub 2 | As a web developer 3 | In order to communicate with my "things" 4 | I want to subscribe and publish to topics 5 | 6 | Scenario: GETting and PUTting 7 | When client "B" publishes "hello world" to "foobar" via HTTP 8 | Then client "A" should have received "hello world" from "foobar" via HTTP 9 | 10 | Scenario: GETting and PUTting JSON 11 | When client "B" publishes "[ 42, 43 ]" to "foobar" via HTTP_JSON 12 | Then client "A" should have received "[42,43]" from "foobar" via HTTP_JSON 13 | 14 | Scenario: GETting and PUTting plain text 15 | When client "B" publishes "hello world" to "foobar" via HTTP_TXT 16 | Then client "A" should have received "hello world" from "foobar" via HTTP_TXT 17 | 18 | Scenario: PUTting JSON and reading from MQTT 19 | Given client "A" subscribe to "foobar" via MQTT 20 | When client "B" publishes "[ 42, 43 ]" to "foobar" via HTTP_JSON 21 | Then client "A" should have received "[42,43]" from "foobar" via MQTT 22 | -------------------------------------------------------------------------------- /features/mqtt_pub_sub.feature: -------------------------------------------------------------------------------- 1 | Feature: MQTT pub/sub 2 | As a MQTT developer 3 | In order to communicate with my "things" 4 | I want to subscribe and publish to topics 5 | 6 | Scenario: Subscribe and publish 2 clients 7 | Given client "A" subscribe to "foobar" via MQTT 8 | When client "B" publishes "hello world" to "foobar" via MQTT 9 | Then client "A" should have received "hello world" from "foobar" via MQTT 10 | 11 | Scenario: Subscribe and publish 1 client 12 | Given client "A" subscribe to "foobar" via MQTT 13 | When client "A" publishes "hello world" to "foobar" via MQTT 14 | Then client "A" should have received "hello world" from "foobar" via MQTT 15 | 16 | Scenario: Always retains the last message 17 | Given client "B" publishes "aaa" to "foobar" via MQTT 18 | When client "A" subscribe to "foobar" via MQTT 19 | Then client "A" should have received "aaa" from "foobar" via MQTT 20 | 21 | Scenario: Subscribe and publish with pattern 22 | Given client "A" subscribe to "foo/#" via MQTT 23 | When client "B" publishes "hello world" to "foo/bar" via MQTT 24 | Then client "A" should have received "hello world" from "foo/bar" via MQTT 25 | 26 | Scenario: Subscribe and publish 3 clients 27 | Given client "A" subscribe to "foobar" via MQTT 28 | And client "B" subscribe to "foobar" via MQTT 29 | When client "C" publishes "hello world" to "foobar" via MQTT 30 | Then client "A" should have received "hello world" from "foobar" via MQTT 31 | Then client "B" should have received "hello world" from "foobar" via MQTT 32 | -------------------------------------------------------------------------------- /features/steps/clients_steps.coffee: -------------------------------------------------------------------------------- 1 | expect = require('chai').expect 2 | 3 | module.exports = -> 4 | @Given /^client "([^"]*)" subscribe to "([^"]*)" via ([^ ]*)$/, (client, topic, protocol, callback) -> 5 | @getClient protocol, client, (client) -> 6 | client.subscribe(topic) 7 | callback() 8 | 9 | @When /^client "([^"]*)" publishes "([^"]*)" to "([^"]*)" via ([^ ]*)$/, (client, message, topic, protocol, callback) -> 10 | @getClient protocol, client, (client) -> 11 | client.publish topic, message, callback 12 | 13 | @Then /^client "([^"]*)" should have received "([^"]*)" from "([^"]*)" via ([^ ]*)$/, (client, message, topic, protocol, callback) -> 14 | @getClient protocol, client, (client) -> 15 | client.getLastMessageFromTopic topic, (lastMessage) -> 16 | expect(lastMessage).to.equal(message) 17 | callback() 18 | -------------------------------------------------------------------------------- /features/steps/http_steps.coffee: -------------------------------------------------------------------------------- 1 | expect = require('chai').expect 2 | 3 | module.exports = () -> 4 | 5 | @When /^I visit "([^"]*)"$/, (url, callback) -> 6 | @browser.visit url, callback 7 | 8 | @Then /^I should see "([^"]*)"$/, (text, callback) -> 9 | expect(@browser.text("body")).to.include(text) 10 | callback() 11 | 12 | @Then /^I should see "([^"]*)" in the textarea$/, (text, callback) -> 13 | doneWaiting = => 14 | expect(@browser.field("textarea").value).to.include(text) 15 | callback() 16 | 17 | if @browser.field("textarea").value.indexOf(text) != -1 18 | callback() 19 | else 20 | setTimeout(doneWaiting, 50) 21 | 22 | 23 | @Then /^I should see the title "([^"]*)"$/, (text, callback) -> 24 | expect(@browser.text("title")).to.equal(text) 25 | callback() 26 | -------------------------------------------------------------------------------- /features/steps/web_client_steps.coffee: -------------------------------------------------------------------------------- 1 | expect = require('chai').expect 2 | 3 | module.exports = () -> 4 | 5 | this.Given /^I open the topic "([^"]*)"$/, (topic, callback) -> 6 | @browser.visit "/", => 7 | @browser.fill "topic", topic, => 8 | @browser.pressButton "GO!", callback 9 | 10 | 11 | this.When /^I change the payload to "([^"]*)"$/, (payload, callback) -> 12 | @browser.pressButton "Edit", => 13 | @browser.fill "payload", payload, => 14 | @browser.pressButton "Update", callback 15 | -------------------------------------------------------------------------------- /features/steps/world_overrider.coffee: -------------------------------------------------------------------------------- 1 | 2 | module.exports = -> 3 | @World = require("../support/world").World 4 | -------------------------------------------------------------------------------- /features/support/clients/http.coffee: -------------------------------------------------------------------------------- 1 | request = require('request') 2 | 3 | class HttpClient 4 | 5 | constructor: (@port, @host) -> 6 | 7 | subscribe: (topic) -> 8 | throw new Error("Not implemented yet") 9 | 10 | publish: (topic, message, callback) -> 11 | request.put(uri: @url(topic), form: { payload: message }, callback) 12 | 13 | getLastMessageFromTopic: (topic, callback) -> 14 | request.get uri: @url(topic), headers: @headers , (err, response, body) -> 15 | callback(body) 16 | 17 | headers: () -> 18 | {} 19 | 20 | disconnect: () -> 21 | 22 | url: (topic) -> 23 | "http://#{@host}:#{@port}/topics/#{topic}" 24 | 25 | HttpClient.build = (opts, callback) -> 26 | callback new HttpClient(opts.port, "127.0.0.1") 27 | 28 | module.exports.HttpClient = HttpClient 29 | -------------------------------------------------------------------------------- /features/support/clients/http_json.coffee: -------------------------------------------------------------------------------- 1 | request = require('request') 2 | 3 | class HttpJsonClient 4 | 5 | constructor: (@port, @host) -> 6 | 7 | subscribe: (topic) -> 8 | throw new Error("Not implemented yet") 9 | 10 | publish: (topic, message, callback) -> 11 | message = JSON.parse(message) 12 | request.put( 13 | uri: @url(topic), 14 | headers: { "Content-Type": "application/json" }, 15 | body: JSON.stringify(message), 16 | callback) 17 | 18 | getLastMessageFromTopic: (topic, callback) -> 19 | request.get uri: @url(topic), headers: { "Accept": "application/json" } , (err, response, body) -> 20 | callback(body) 21 | 22 | disconnect: () -> 23 | 24 | url: (topic) -> 25 | "http://#{@host}:#{@port}/topics/#{topic}" 26 | 27 | HttpJsonClient.build = (opts, callback) -> 28 | callback new HttpJsonClient(opts.port, "127.0.0.1") 29 | 30 | module.exports.HttpJsonClient = HttpJsonClient 31 | -------------------------------------------------------------------------------- /features/support/clients/http_txt.coffee: -------------------------------------------------------------------------------- 1 | 2 | { HttpClient } = require './http' 3 | 4 | class HttpTxtClient extends HttpClient 5 | headers: -> 6 | { "Accept": "text/plain" } 7 | 8 | HttpTxtClient.build = (opts, callback) -> 9 | callback new HttpTxtClient(opts.port, "127.0.0.1") 10 | 11 | module.exports.HttpTxtClient = HttpTxtClient 12 | -------------------------------------------------------------------------------- /features/support/clients/mqtt.coffee: -------------------------------------------------------------------------------- 1 | mqtt = require('mqttjs') 2 | 3 | class MqttClient 4 | 5 | constructor: (@client) -> 6 | @last_packets = {} 7 | 8 | @client.on 'publish', (packet) => 9 | @last_packets[packet.topic] = packet.payload 10 | 11 | subscribe: (topic) -> 12 | @client.subscribe(topic: topic) 13 | 14 | publish: (topic, message, callback) -> 15 | @client.publish(topic: topic, payload: message) 16 | callback() 17 | 18 | disconnect: -> 19 | @client.disconnect() 20 | 21 | getLastMessageFromTopic: (topic, callback) -> 22 | last_packet = @last_packets[topic] 23 | if last_packet? 24 | callback(last_packet) 25 | return 26 | 27 | listenToPublish = (packet) => 28 | if packet.topic == topic 29 | callback(packet.payload) 30 | @client.removeListener(topic, listenToPublish) 31 | 32 | @client.on('publish', listenToPublish) 33 | 34 | counter = 0 35 | 36 | MqttClient.build = (opts, callback) -> 37 | mqtt.createClient opts.mqtt, "127.0.0.1", (err, client) => 38 | throw new Error(err) if err? 39 | client.connect(client: "cucumber #{counter++}!", keepalive: 3000) 40 | 41 | client.on 'connack', (packet) -> 42 | if packet.returnCode == 0 43 | callback(new MqttClient(client)) 44 | else 45 | console.log('connack error %d', packet.returnCode) 46 | throw new Error("connack error #{packet.returnCode}") 47 | 48 | module.exports.MqttClient = MqttClient 49 | -------------------------------------------------------------------------------- /features/support/clients_helpers.coffee: -------------------------------------------------------------------------------- 1 | 2 | module.exports = -> 3 | @After (done) -> 4 | for name, client of @clients 5 | client.disconnect() 6 | done() 7 | 8 | -------------------------------------------------------------------------------- /features/support/reset_db_hook.coffee: -------------------------------------------------------------------------------- 1 | 2 | module.exports = () -> 3 | @Before (done) -> 4 | @app.models.Data.reset?() 5 | @app.redis.client.flushdb => 6 | done() 7 | -------------------------------------------------------------------------------- /features/support/world.coffee: -------------------------------------------------------------------------------- 1 | env = require("../../qest.coffee") 2 | zombie = require('zombie') 3 | 4 | opts = 5 | port: 9777 6 | mqtt: 9778 7 | redisHost: "127.0.0.1" 8 | redisPort: 6379 9 | redisDB: 16 10 | 11 | app = env.start opts 12 | browser = new zombie.Browser(site: "http://localhost:#{opts.port}", headers: { "Accept": "text/html" }) 13 | 14 | { MqttClient } = require("./clients/mqtt") 15 | { HttpClient } = require("./clients/http") 16 | { HttpJsonClient } = require("./clients/http_json") 17 | { HttpTxtClient } = require("./clients/http_txt") 18 | 19 | protocols = 20 | HTTP: HttpClient 21 | HTTP_JSON: HttpJsonClient 22 | HTTP_TXT: HttpTxtClient 23 | MQTT: MqttClient 24 | 25 | exports.World = (callback) -> 26 | @browser = browser 27 | @opts = opts 28 | @app = app 29 | 30 | @clients = {} 31 | @getClient = (protocol, name, callback) => 32 | if @clients[name]? 33 | callback(@clients[name]) 34 | else 35 | protocols[protocol].build @opts, (client) => 36 | @clients[name] = client 37 | callback(client) 38 | 39 | callback() 40 | -------------------------------------------------------------------------------- /features/web_interface.feature: -------------------------------------------------------------------------------- 1 | Feature: Web Interface 2 | As QEST user 3 | I want to go to one of my topics 4 | 5 | Scenario: go to topic page 6 | When I open the topic "mytopic" 7 | Then I should see "mytopic" 8 | 9 | Scenario: see the published value of a topic 10 | Given client "A" publishes "hello world" to "mytopic" via HTTP 11 | When I open the topic "mytopic" 12 | Then I should see "hello world" in the textarea 13 | 14 | Scenario: receives the updates from a topic 15 | Given I open the topic "mytopic" 16 | When client "A" publishes "hello world" to "mytopic" via HTTP 17 | Then I should see "hello world" in the textarea 18 | 19 | Scenario: send the update of a topic from the web to the devices 20 | Given client "A" subscribe to "mytopic" via MQTT 21 | And I open the topic "mytopic" 22 | When I change the payload to "hello world" 23 | Then client "A" should have received "hello world" from "mytopic" via MQTT 24 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "qest", 3 | "version": "0.0.1", 4 | "description": "A REST bridge to MQTT", 5 | "keywords": [ 6 | "MQTT", 7 | "WebSockets", 8 | "REST" 9 | ], 10 | "author": "hello@matteocollina.com", 11 | "licenses": [ 12 | "MIT" 13 | ], 14 | "dependencies": { 15 | "coffee-script": "~1.3.3", 16 | "optimist": "= 0.3.4", 17 | "express": "~3.0.1", 18 | "hbs": "= 1.0.4", 19 | "socket.io": "= 0.9.10", 20 | "mqttjs": "= 0.1.7", 21 | "redis": "~0.8.1", 22 | "hiredis": "= 0.1.14", 23 | "connect-redis": "1.4.0", 24 | "markdown": "0.3.1", 25 | "mincer": "0.3.0", 26 | "less": "~1.3.0", 27 | "ascoltatori": "0.0.4", 28 | "async": "~0.1.22" 29 | }, 30 | "devDependencies": { 31 | "cucumber": "= 0.2.21", 32 | "zombie": "= 1.4.0", 33 | "mocha": "= 1.2.2", 34 | "sinon": "= 1.3.4", 35 | "chai": "= 1.1.0", 36 | "benchmark": "git://github.com/bestiejs/benchmark.js.git#master", 37 | "request": "= 2.10.0" 38 | }, 39 | "scripts": { 40 | "test": "./node_modules/.bin/cake spec && ./node_modules/.bin/cake features" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcollina/qest/810f720b61c47fa74cdf7bb2e3ea616c4e548ffd/public/favicon.ico -------------------------------------------------------------------------------- /public/images/grey.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcollina/qest/810f720b61c47fa74cdf7bb2e3ea616c4e548ffd/public/images/grey.png -------------------------------------------------------------------------------- /public/images/schema.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcollina/qest/810f720b61c47fa74cdf7bb2e3ea616c4e548ffd/public/images/schema.png -------------------------------------------------------------------------------- /public/mu-942f19e0-e626e170-21f6d708-caa97289.txt: -------------------------------------------------------------------------------- 1 | 42 2 | -------------------------------------------------------------------------------- /public/stylesheets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcollina/qest/810f720b61c47fa74cdf7bb2e3ea616c4e548ffd/public/stylesheets/.gitkeep -------------------------------------------------------------------------------- /qest.coffee: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env coffee 2 | 3 | # Module dependencies. 4 | 5 | optimist = require 'optimist' 6 | express = require 'express' 7 | path = require 'path' 8 | fs = require 'fs' 9 | hbs = require 'hbs' 10 | redis = require 'redis' 11 | mqtt = require "mqttjs" 12 | EventEmitter = require('events').EventEmitter 13 | RedisStore = require('connect-redis')(express) 14 | ascoltatori = require('ascoltatori') 15 | 16 | # Create Server 17 | 18 | module.exports.app = app = express() 19 | http = require('http').createServer(app) 20 | 21 | # Configuration 22 | 23 | app.redis = {} 24 | 25 | module.exports.configure = configure = -> 26 | app.configure 'development', -> 27 | app.use(express.errorHandler({ dumpExceptions: true, showStack: true })) 28 | 29 | app.configure 'production', -> 30 | app.use(express.errorHandler()) 31 | 32 | app.configure -> 33 | app.set('views', __dirname + '/app/views') 34 | app.set('view engine', 'hbs') 35 | app.use(express.bodyParser()) 36 | app.use(express.methodOverride()) 37 | app.use(express.cookieParser()) 38 | app.use(express.session(secret: "wyRLuS5A79wLn3ItlGVF61Gt", 39 | store: new RedisStore(client: app.redis.client), maxAge: 1000 * 60 * 60 * 24 * 14)) # two weeks 40 | 41 | app.use(app.router) 42 | app.use(express.static(__dirname + '/public')) 43 | 44 | # setup websockets 45 | io = app.io = require('socket.io').listen(http) 46 | 47 | io.configure 'production', -> 48 | io.enable('browser client minification'); # send minified client 49 | io.enable('browser client etag'); # apply etag caching logic based on version number 50 | io.enable('browser client gzip'); # gzip the file 51 | io.set('log level', 0) 52 | 53 | io.configure 'test', -> 54 | io.set('log level', 0) 55 | 56 | load("models") 57 | load("controllers") 58 | load("helpers") 59 | 60 | load = (key) -> 61 | app[key] = {} 62 | loadPath = __dirname + "/app/#{key}/" 63 | for component in fs.readdirSync(loadPath) 64 | if component.match /(js|coffee)$/ 65 | component = path.basename(component, path.extname(component)) 66 | loadedModule = require(loadPath + component)(app) 67 | component = loadedModule.name if loadedModule?.name? and loadedModule.name != "" 68 | app[key][component] = loadedModule 69 | 70 | # Start the module if it's needed 71 | 72 | optionParser = optimist. 73 | default('port', 3000). 74 | default('mqtt', 1883). 75 | default('redis-port', 6379). 76 | default('redis-host', '127.0.0.1'). 77 | default('redis-db', 0). 78 | usage("Usage: $0 [-p WEB-PORT] [-m MQTT-PORT] [-rp REDIS-PORT] [-rh REDIS-HOST]"). 79 | alias('port', 'p'). 80 | alias('mqtt', 'm'). 81 | alias('redis-port', 'rp'). 82 | alias('redis-host', 'rh'). 83 | alias('redis-db', 'rd'). 84 | describe('port', 'The port the web server will listen to'). 85 | describe('mqtt', 'The port the mqtt server will listen to'). 86 | describe('redis-port', 'The port of the redis server'). 87 | describe('redis-host', 'The host of the redis server'). 88 | boolean("help"). 89 | describe("help", "This help") 90 | 91 | argv = optionParser.argv 92 | 93 | module.exports.setupAscoltatore = setupAscoltatore = (opts = {}) -> 94 | app.ascoltatore = new ascoltatori.RedisAscoltatore 95 | redis: redis 96 | port: opts.port 97 | host: opts.host 98 | db: opts.db 99 | 100 | module.exports.setup = setup = (opts = {}) -> 101 | args = [opts.port, opts.host] 102 | app.redis.client = redis.createClient(args...) 103 | app.redis.client.select(opts.db || 0) 104 | 105 | setupAscoltatore(opts) 106 | 107 | start = module.exports.start = (opts={}, cb=->) -> 108 | 109 | opts.port ||= argv.port 110 | opts.mqtt ||= argv.mqtt 111 | opts.redisPort ||= argv['redis-port'] 112 | opts.redisHost ||= argv['redis-host'] 113 | opts.redisDB ||= argv['redis-db'] 114 | 115 | if argv.help 116 | optionParser.showHelp() 117 | return 1 118 | 119 | setup(port: opts.redisPort, host: opts.redisHost, db: opts.redisDB) 120 | configure() 121 | 122 | countDone = 0 123 | done = -> 124 | cb() if countDone++ == 2 125 | 126 | http.listen opts.port, -> 127 | console.log("mqtt-rest web server listening on port %d in %s mode", opts.port, app.settings.env) 128 | done() 129 | 130 | mqtt.createServer(app.controllers.mqtt_api).listen opts.mqtt, -> 131 | console.log("mqtt-rest mqtt server listening on port %d in %s mode", opts.mqtt, app.settings.env) 132 | done() 133 | 134 | app 135 | 136 | if require.main.filename == __filename 137 | start() 138 | -------------------------------------------------------------------------------- /qest.js: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env node 2 | 3 | require('coffee-script') 4 | app = require("./qest.coffee") 5 | app.start() 6 | -------------------------------------------------------------------------------- /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --require chai 2 | --require sinon 3 | --reporter dot 4 | --ui bdd 5 | --growl 6 | --colors 7 | --globals s 8 | -------------------------------------------------------------------------------- /test/models/data_spec.coffee: -------------------------------------------------------------------------------- 1 | 2 | helper = require("../spec_helper") 3 | 4 | expect = require('chai').expect 5 | async = require("async") 6 | 7 | describe "Data", -> 8 | 9 | models = null 10 | 11 | before -> 12 | helper.globalSetup() 13 | models = helper.app.models 14 | 15 | after -> 16 | helper.globalTearDown() 17 | 18 | beforeEach (done) -> 19 | helper.setup(done) 20 | 21 | afterEach (done) -> 22 | helper.tearDown(done) 23 | 24 | it "should have a findOrCreate method", -> 25 | expect(models.Data.findOrCreate).to.exist 26 | 27 | it "should findOrCreate a new instance with a key", (done) -> 28 | models.Data.findOrCreate "key", (err, data) => 29 | expect(data).to.eql(new models.Data("key")) 30 | done() 31 | 32 | it "should findOrCreate a new instance with a key and a value", (done) -> 33 | models.Data.findOrCreate "aaa", "bbbb", (err, data) => 34 | expect(data).to.eql(new models.Data("aaa", "bbbb")) 35 | done() 36 | 37 | it "should findOrCreate an old instance overriding the value", (done) -> 38 | models.Data.findOrCreate "aaa", "bbbb", => 39 | models.Data.findOrCreate "aaa", "ccc", => 40 | models.Data.find "aaa", (err, data) => 41 | expect(data).to.eql(new models.Data("aaa", "ccc")) 42 | done() 43 | 44 | it "should publish an update when calling findOrCreate", (done) -> 45 | models.Data.subscribe "aaa", (data) => 46 | done() 47 | models.Data.findOrCreate "aaa", "bbbb" 48 | 49 | it "should allow subscribing in the create step", (done) -> 50 | models.Data.findOrCreate "aaa", (err, data) => 51 | models.Data.subscribe "aaa", (curr) -> 52 | done() if curr.value == "ccc" 53 | 54 | data.value = "ccc" 55 | data.save() 56 | 57 | it "should allow unsubscribing in the create step", (done) -> 58 | models.Data.findOrCreate "aaa", (err, data) => 59 | func = -> throw "This should never be called" 60 | 61 | models.Data.subscribe "aaa", func 62 | models.Data.unsubscribe "aaa", func 63 | 64 | models.Data.subscribe "aaa", (curr) -> 65 | done() 66 | 67 | data.save() 68 | 69 | it "should provide a find method that returns an error if there is no obj", (done) -> 70 | models.Data.find "obj", (err, data) => 71 | expect(err).to.eql("Record not found") 72 | done() 73 | 74 | it "should provide a find method that uses a regexp for matching", (done) -> 75 | results = [] 76 | 77 | async.parallel([ 78 | async.apply(models.Data.findOrCreate, "hello bob", "aaa"), 79 | async.apply(models.Data.findOrCreate, "hello mark", "aaa"), 80 | ], -> 81 | models.Data.find /hello .*/, (err, data) -> 82 | results.push(data.key) unless err? 83 | if results.length == 2 84 | expect(results).to.contain("hello bob") 85 | expect(results).to.contain("hello mark") 86 | done() 87 | ) 88 | 89 | it "should provide a subscribe method that works for new topics", (done) -> 90 | results = [] 91 | models.Data.subscribe "hello/*", (data) -> 92 | results.push(data.key) 93 | if results.length == 2 94 | expect(results).to.contain("hello/bob") 95 | expect(results).to.contain("hello/mark") 96 | done() 97 | 98 | async.parallel([ 99 | async.apply(models.Data.findOrCreate, "hello/bob", "aaa"), 100 | async.apply(models.Data.findOrCreate, "hello/mark", "aaa"), 101 | ]) 102 | 103 | describe "instance", -> 104 | 105 | it "should get the key", -> 106 | subject = new models.Data("key", "value") 107 | expect(subject.key).to.eql("key") 108 | 109 | it "should get the key (dis)", -> 110 | subject = new models.Data("aaa") 111 | expect(subject.key).to.eql("aaa") 112 | 113 | it "should get the value", -> 114 | subject = new models.Data("key", "value") 115 | expect(subject.value).to.eql("value") 116 | 117 | it "should get the value (dis)", -> 118 | subject = new models.Data("key", "aaa") 119 | expect(subject.value).to.eql("aaa") 120 | 121 | it "should get the redisKey", -> 122 | subject = new models.Data("key", "value") 123 | expect(subject.redisKey).to.eql("topic:key") 124 | 125 | it "should get the redisKey (dis)", -> 126 | subject = new models.Data("aaa/42", "value") 127 | expect(subject.redisKey).to.eql("topic:aaa/42") 128 | 129 | it "should accept an object as value in the constructor", -> 130 | obj = { hello: 42 } 131 | subject = new models.Data("key", obj) 132 | expect(subject.value).to.eql(obj) 133 | 134 | it "should export its value as JSON", -> 135 | obj = { hello: 42 } 136 | subject = new models.Data("key", obj) 137 | expect(subject.jsonValue).to.eql(JSON.stringify(obj)) 138 | 139 | it "should export its value as JSON when setting the value", -> 140 | obj = { hello: 42 } 141 | subject = new models.Data("key") 142 | subject.value = obj 143 | expect(subject.jsonValue).to.eql(JSON.stringify(obj)) 144 | 145 | it "should set the value", -> 146 | subject = new models.Data("key") 147 | subject.value = "bbb" 148 | expect(subject.value).to.eql("bbb") 149 | 150 | it "should set the value (dis)", -> 151 | subject = new models.Data("key") 152 | subject.value = "ccc" 153 | expect(subject.value).to.eql("ccc") 154 | 155 | it "should set the json value", -> 156 | subject = new models.Data("key") 157 | subject.jsonValue = JSON.stringify("ccc") 158 | expect(subject.value).to.eql("ccc") 159 | 160 | it "should have a save method", -> 161 | subject = new models.Data("key") 162 | expect(subject.save).to.exist 163 | 164 | it "should save an array", (done) -> 165 | subject = new models.Data("key") 166 | subject.value = [1, 2] 167 | subject.save => 168 | done() 169 | 170 | it "should support subscribing for change", (done) -> 171 | subject = new models.Data("key") 172 | subject.save => 173 | models.Data.subscribe subject.key, (data) => 174 | expect(data.value).to.equal("aaaa") 175 | done() 176 | 177 | subject.value = "aaaa" 178 | subject.save() 179 | 180 | it "should register for change before creation", (done) -> 181 | subject = new models.Data("key") 182 | models.Data.subscribe subject.key, (data) => 183 | expect(data.value).to.equal("aaaa") 184 | done() 185 | 186 | subject.value = "aaaa" 187 | subject.save() 188 | 189 | it "should save and findOrCreate", (done) -> 190 | subject = new models.Data("key") 191 | subject.save => 192 | models.Data.findOrCreate subject.key, (err, data) => 193 | expect(data).to.eql(subject) 194 | done() 195 | 196 | it "should save and find", (done) -> 197 | subject = new models.Data("key") 198 | subject.save => 199 | models.Data.find subject.key, (err, data) => 200 | expect(data).to.eql(subject) 201 | done() 202 | 203 | it "should not persist the value before save", (done) -> 204 | subject = new models.Data("key") 205 | subject.save => 206 | subject.value = "ccc" 207 | models.Data.find subject.key, (err, data) -> 208 | expect(data.value).to.not.eql("ccc") 209 | done() 210 | -------------------------------------------------------------------------------- /test/spec_helper.coffee: -------------------------------------------------------------------------------- 1 | 2 | env = require("../qest.coffee") 3 | async = require("async") 4 | 5 | config = 6 | host: "127.0.0.1" 7 | port: 6379 8 | db: 16 9 | 10 | module.exports.globalSetup = -> 11 | return if @app? 12 | @app = env.app 13 | env.setup(config) 14 | env.configure() 15 | 16 | module.exports.globalTearDown = -> 17 | @app.redis.client.end() 18 | 19 | module.exports.setup = (done) -> 20 | env.setupAscoltatore(config) 21 | async.parallel([ 22 | (cb) => @app.ascoltatore.once("ready", cb), 23 | (cb) => @app.redis.client.flushdb(cb) 24 | ], done) 25 | 26 | module.exports.tearDown = (done) -> 27 | @app.ascoltatore.close(done) 28 | 29 | --------------------------------------------------------------------------------