├── .gitignore ├── DEPENDENCIES ├── .travis.yml ├── admin ├── public │ ├── stylesheets │ │ └── style.css │ └── lib │ │ └── bootstrap │ │ ├── img │ │ ├── glyphicons-halflings.png │ │ └── glyphicons-halflings-white.png │ │ ├── css │ │ ├── bootstrap-responsive.min.css │ │ ├── bootstrap-responsive.css │ │ └── bootstrap.min.css │ │ └── js │ │ ├── bootstrap.min.js │ │ └── bootstrap.js ├── package.json ├── views │ ├── layout.jade │ ├── login.jade │ ├── featureForm.jade │ └── index.jade ├── routes │ ├── index.js │ └── api.js └── app.js ├── .jshintrc ├── gulpfile.js ├── storage ├── ff_memcache.js ├── ff_redis.js └── ff_memory.js ├── test ├── core.js ├── usage_example_test.js ├── storage_test.js ├── api_test.js └── feature_test.js ├── package.json ├── sample_app └── frontend.js ├── README.textile └── feature_flipper.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | -------------------------------------------------------------------------------- /DEPENDENCIES: -------------------------------------------------------------------------------- 1 | NPM packages on which this app depends: 2 | 3 | * mocha 4 | * should 5 | * redis -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 0.10 4 | 5 | services: 6 | - redis-server 7 | - memcached 8 | 9 | sudo: false 10 | script: npm test 11 | -------------------------------------------------------------------------------- /admin/public/stylesheets/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | padding: 50px; 3 | font: 14px "Lucida Grande", Helvetica, Arial, sans-serif; 4 | } 5 | 6 | a { 7 | color: #00B7FF; 8 | } -------------------------------------------------------------------------------- /admin/public/lib/bootstrap/img/glyphicons-halflings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bigodines/feature-flipper-js/HEAD/admin/public/lib/bootstrap/img/glyphicons-halflings.png -------------------------------------------------------------------------------- /admin/public/lib/bootstrap/img/glyphicons-halflings-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bigodines/feature-flipper-js/HEAD/admin/public/lib/bootstrap/img/glyphicons-halflings-white.png -------------------------------------------------------------------------------- /admin/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "application-name" 3 | , "version": "0.0.1" 4 | , "private": true 5 | , "dependencies": { 6 | "express": "2.5.8" 7 | , "jade": ">= 0.0.1" 8 | } 9 | } -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "node": true, 3 | "esnext": true, 4 | "bitwise": true, 5 | "curly": true, 6 | "immed": true, 7 | "newcap": true, 8 | "noarg": true, 9 | "undef": true, 10 | "unused": "vars", 11 | "strict": true 12 | } 13 | -------------------------------------------------------------------------------- /admin/views/layout.jade: -------------------------------------------------------------------------------- 1 | !!! 2 | html 3 | head 4 | title= 'Feature Flipper - Admin Tool' 5 | link(rel='stylesheet', href='/lib/bootstrap/css/bootstrap.min.css') 6 | link(rel='stylesheet', href='/stylesheets/style.css') 7 | script(src='/lib/jquery/jquery-1.7.2.min.js') 8 | script(src='/lib/bootstrap/js/bootstrap.min.js') 9 | 10 | 11 | body!= body -------------------------------------------------------------------------------- /admin/views/login.jade: -------------------------------------------------------------------------------- 1 | !!! 5 2 | div.container 3 | div.row 4 | 5 | h1 Feature Flipper - Admin Tool 6 | 7 | div.row 8 | div.offset4.span4 9 | form#login(method="post", action="/") 10 | label 11 | | Username: 12 | input(type="text",name="username") 13 | label 14 | | Password: 15 | input(type="password", name="userpass") 16 | 17 | input(type="submit", name="submit", value="submit") 18 | -------------------------------------------------------------------------------- /admin/views/featureForm.jade: -------------------------------------------------------------------------------- 1 | !!! 5 2 | div.container 3 | div.row 4 | 5 | h1 Feature Flipper - Admin Tool 6 | 7 | div.row 8 | div.offset4.span4 9 | form#feature(method="post", action="#{target}") 10 | label 11 | | Id: 12 | input(type="text",name="id") 13 | label 14 | | Description: 15 | input(type="text", name="description") 16 | 17 | input(type="submit", name="submit", value="submit") 18 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | var jshint = require('gulp-jshint'); 2 | var gulp = require('gulp'); 3 | var child_process = require('child_process'); 4 | 5 | gulp.task('start', function() { 6 | console.log('did nothing :P'); 7 | }); 8 | 9 | gulp.task('lint', function() { 10 | gulp.src(['feature_flipper.js', 'storage/*']) 11 | .pipe(jshint('.jshintrc')) 12 | .pipe(jshint.reporter('jshint-stylish')); 13 | }) 14 | 15 | gulp.task('redis-start', function() { 16 | child_process.exec('redis-server', function(err, stdout, stderr) { 17 | console.log(stdout); 18 | if (err !== null) { 19 | console.log('exec error: ' + err); 20 | } 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /storage/ff_memcache.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | (function () { 3 | var _memjs = require('memjs'); 4 | var ff_memcache = function(servers, options) { 5 | var mc = _memjs.Client.create(servers, options); 6 | return { 7 | name: 'memcache', 8 | set: function(key, value, cb) { 9 | mc.set(key, value, cb); 10 | }, 11 | 12 | get: function(key, cb) { 13 | mc.get(key, cb); 14 | }, 15 | 16 | del: function(key, cb) { 17 | mc.delete(key, cb); 18 | } 19 | } 20 | } 21 | 22 | module.exports = ff_memcache; 23 | })(); 24 | -------------------------------------------------------------------------------- /admin/views/index.jade: -------------------------------------------------------------------------------- 1 | !!! 5 2 | div.container-fluid 3 | div.row-fluid 4 | h1 Feature Flipper - Admin Tool 5 | p Here you'll find your features! 6 | 7 | div.row-fluid 8 | table.table.table-striped 9 | thead 10 | tr 11 | th feature description 12 | th enabled to all 13 | th enabled to some 14 | 15 | if typeof(features) !== 'undefined' 16 | for feature in features 17 | tr 18 | td #{feature.description} 19 | td ?? 20 | td ?? 21 | 22 | a add feature 23 | 24 | 25 | p bye -------------------------------------------------------------------------------- /test/core.js: -------------------------------------------------------------------------------- 1 | var redis = require('redis'), 2 | should = require('should'), 3 | client = redis.createClient(); 4 | 5 | /* simple test to check if redis is running and working as expected */ 6 | describe('redis is working', function () { 7 | var key_to_set = 'cat', 8 | value_to_set = 'margarida'; 9 | 10 | describe('#set()', function() { 11 | it('should set without errors', function(done) { 12 | client.set(key_to_set, value_to_set, done); 13 | }); 14 | }); 15 | 16 | describe('#get()', function() { 17 | it('should get the proper value', function(done) { 18 | client.get(key_to_set, function(err, data) { 19 | should.not.exist(err); 20 | data.should.equal(value_to_set); 21 | done(); 22 | }); 23 | }); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /storage/ff_redis.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | (function () { 3 | var _redis = require('redis'), 4 | 5 | ff_redis = function (port, host, options) { 6 | var redis = _redis.createClient(port, host, options); 7 | /* public methods*/ 8 | return { 9 | name: 'redis', 10 | set: function (key, value, cb) { 11 | redis.set(key, value, function (err, data) { 12 | if (!cb) { 13 | return; 14 | } 15 | cb(err, data); 16 | }); 17 | }, 18 | get: function (key, cb) { 19 | redis.get(key, function (err, data) { 20 | if (!cb) { 21 | return; 22 | } 23 | cb(err, data); 24 | }); 25 | 26 | }, 27 | del: function (key, cb) { 28 | redis.del(key, cb); 29 | } 30 | }; 31 | }; 32 | 33 | module.exports = ff_redis; 34 | })(); 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "feature-flipper-js", 3 | "version": "0.1.0", 4 | "description": "A Feature Flipper for nodejs", 5 | "preferGlobal": "true", 6 | "main": "feature_flipper.js", 7 | "author": { 8 | "name": "Matheus Mendes", 9 | "email": "", 10 | "twitter": "bigodines" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "https://bigodines@github.com/bigodines/feature-flipper-js.git" 15 | }, 16 | "dependencies": { 17 | "lodash": "3.7.0", 18 | "memjs": "^0.8.7", 19 | "redis": "^0.7.1" 20 | }, 21 | "devDependencies": { 22 | "gulp": "3.9.0", 23 | "gulp-jshint": "2.0.0", 24 | "jshint": "*", 25 | "jshint-stylish": "2.1.0", 26 | "mocha": "2.3.4", 27 | "should": "7.1.1" 28 | }, 29 | "scripts": { 30 | "test": "./node_modules/.bin/mocha -C -R spec" 31 | }, 32 | "license": "BSD", 33 | "engines": { 34 | "node": "*" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /storage/ff_memory.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | (function () { 4 | var features = []; 5 | var ff_memory = function() { 6 | /* 7 | * This is the bare basics storage for features. 8 | * It has been written so that one does not have to have 9 | * redis or memcache or any other external tools to make sure 10 | * feature_flipper's code is working 11 | **/ 12 | 13 | return { 14 | name: 'memory', 15 | set: function(key, value, cb) { 16 | features[key] = value; 17 | cb(null, features[key]); 18 | }, 19 | 20 | get: function(key, cb) { 21 | cb(null, features[key]); 22 | }, 23 | 24 | del: function(key, cb) { 25 | if (delete features[key]) { 26 | cb(null, features); 27 | } else { 28 | cb('could not delete', null); // explicitly passing "null" as data to improve readability 29 | } 30 | } 31 | } 32 | } 33 | 34 | module.exports = ff_memory; 35 | })(); 36 | -------------------------------------------------------------------------------- /admin/routes/index.js: -------------------------------------------------------------------------------- 1 | var redis = require('redis').createClient(); 2 | 3 | /* 4 | * GET home page. 5 | */ 6 | 7 | exports.index = function(req, res){ 8 | redis.keys('feature:*', function(err, data) { 9 | var features = []; 10 | var a = redis.mget(data, function(err, all_features) { 11 | if (all_features !== undefined) { 12 | for(var i=0; i < all_features.length; i++) { 13 | var feature = JSON.parse(all_features[i]); 14 | features.push(feature); 15 | } 16 | } 17 | res.render('index', { features : features }); 18 | }); 19 | }); 20 | }; 21 | 22 | exports.login = function(req, res) { 23 | var user = req.body.username; 24 | var password = req.body.userpass; 25 | 26 | if (user === 'admin' && password === 'featureflipper') { 27 | req.session.login = 'admin'; 28 | exports.index(req,res); 29 | } 30 | else { 31 | res.render('login', {error: 'Invalid Login'}); 32 | } 33 | }; 34 | 35 | exports.createFeature = function(req, res) { 36 | res.render('featureForm', {target: '/v1/create'}); 37 | } 38 | -------------------------------------------------------------------------------- /test/usage_example_test.js: -------------------------------------------------------------------------------- 1 | var feature_flipper = require('../feature_flipper'), 2 | ff_redis = require('../storage/ff_redis')(); 3 | 4 | /* Usage Scenarios (not tests) */ 5 | describe('Feature Flipper Usage Examples', function() { 6 | describe('Happy paths', function() { 7 | /* Create your set of features*/ 8 | var ff = feature_flipper(ff_redis); 9 | ff.save(ff.create_feature({id: 'header', description: 'Site header', enabledTo : 'all'})); 10 | ff.save(ff.create_feature({id: 'new_message_bar_layout', description: 'Testing the new messagebar layout with some users', enabledTo : ['johnny', 'mark', 'ron', 'some executive']})); 11 | ff.save(ff.create_feature({id: 'bugfix #35', description: 'Attempt to fix bug #35 of some product', enabledTo : ['qa_user_1', 'tati', 'johnny']})); 12 | 13 | var render_feature = function(){}, render_something_else = function(){}; 14 | 15 | it('should allow johnny to see every feature', function(done) { 16 | ff.check('header', 'johnny', function(is_enabled) { 17 | if (is_enabled) { 18 | render_feature(); 19 | } else { 20 | render_something_else(); 21 | } 22 | }); 23 | 24 | ff.check('new_message_bar_layout', 'johnny', function(is_enabled) { 25 | return is_enabled ? render_feature() : render_something_else(); 26 | }); 27 | 28 | ff.check('bugfix #35', 'johnny', function(is_enabled) { 29 | return is_enabled ? render_feature() : render_something_else(); 30 | }); 31 | done(); 32 | }); 33 | 34 | }); 35 | 36 | }); 37 | -------------------------------------------------------------------------------- /test/storage_test.js: -------------------------------------------------------------------------------- 1 | var should = require('should'); 2 | 3 | describe('In-memory storage tests', function () { 4 | it('should set/get/delete just like the other storage engines', function(done) { 5 | var storage = require('../storage/ff_memory')(); 6 | run_storage_tests(storage, done); 7 | }); 8 | }); 9 | 10 | describe('Redis storage tests', function () { 11 | it('should set/get/delete just like the other storage engines', function(done) { 12 | var storage = require('../storage/ff_redis')(6379, 'localhost'); 13 | run_storage_tests(storage, done); 14 | }); 15 | }); 16 | 17 | describe('Memcache storage tests', function () { 18 | it('should set/get/delete just like other storage engines', function (done) { 19 | var storage = require('../storage/ff_memcache')(/*servers, options */); 20 | run_storage_tests(storage, done); 21 | }); 22 | }); 23 | 24 | function run_storage_tests(storage, done) { 25 | var serialized_object = "{ 'foo' : 'bar', 'john' : 'doe' }"; 26 | var delete_cb = function() { 27 | storage.del('id', function() { 28 | storage.get('id', function(err, data) { 29 | should.equal(null, data); 30 | done(); 31 | }) 32 | }) 33 | }; 34 | var get_cb = function(e, val) { 35 | storage.get('id', function (e, data) { 36 | should.equal(null, e); 37 | should.equal(serialized_object, data); 38 | delete_cb(); 39 | }); 40 | }; 41 | storage.set('id', serialized_object, function(err, val) { 42 | should.equal(null, err); 43 | get_cb(); 44 | }); 45 | } 46 | -------------------------------------------------------------------------------- /admin/app.js: -------------------------------------------------------------------------------- 1 | /* 2 | Feature flipper admin tool 3 | 4 | The aim of this app is to provide a way to manage your features throught feature flipper. Of course you are not required to use this in order to use the library itself!!! 5 | */ 6 | 7 | /** 8 | * Module dependencies. 9 | */ 10 | 11 | var express = require('express'), 12 | routes = require('./routes'), 13 | api = require('./routes/api'), 14 | redis = require('redis'); 15 | var app = module.exports = express.createServer(); 16 | // Configuration 17 | 18 | var check_login = function(req, res, next) { 19 | if (req.session && req.session.login) { 20 | next(); 21 | return; 22 | } 23 | res.render('login', { error: 'Please login first' } ); 24 | } 25 | 26 | 27 | app.configure(function(){ 28 | app.set('views', __dirname + '/views'); 29 | app.set('view engine', 'jade'); 30 | app.use(express.bodyParser()); 31 | app.use(express.methodOverride()); 32 | app.use(express.cookieParser()); 33 | app.use(express.session({ secret: 'my very secret key', redis : redis })); 34 | app.use(app.router); 35 | app.use(express.static(__dirname + '/public')); 36 | 37 | }); 38 | 39 | app.configure('development', function() { 40 | app.use(express.errorHandler({ dumpExceptions: true, showStack: true })); 41 | }); 42 | 43 | app.configure('production', function(){ 44 | app.use(express.errorHandler()); 45 | }); 46 | 47 | // API method calls (read the tests to know how to use them) 48 | app.get('/v1/create', api.create); 49 | app.get('/v1/check', api.check); 50 | app.post('/v1/enableTo', api.enableTo); 51 | app.post('/v1/disableTo', api.disableTo); 52 | app.post('/v1/remove', api.remove); 53 | 54 | // Sample app Routes 55 | app.post('/', routes.login); 56 | app.get('/', check_login, routes.index); 57 | app.get('/create', /*check_login,*/ routes.createFeature); 58 | 59 | 60 | 61 | app.listen(3000); 62 | console.log("Express server listening on port %d in %s mode", app.address().port, app.settings.env); 63 | -------------------------------------------------------------------------------- /admin/routes/api.js: -------------------------------------------------------------------------------- 1 | /* api contains an internal instance of feature_flipper that will be used in all public methods */ 2 | var feature_flipper = require('../../feature_flipper'), 3 | ff_redis = require('../../storage/ff_redis')(); 4 | 5 | var ff = feature_flipper(ff_redis); 6 | 7 | exports.create = function(req, res) { 8 | var handle_result = function(err, data) { 9 | if (err === null) { 10 | res.send(JSON.stringify(new_feature)); 11 | } else { 12 | res.status(400); 13 | res.send(JSON.stringify({error: "400", message: "Invalid input"})); 14 | } 15 | }; 16 | var raw_feature = ff.create_feature(req.body); 17 | var new_feature = ff.save(raw_feature, handle_result); 18 | }; 19 | 20 | exports.enableTo = function(req, res) { 21 | var handle_result = function(changed_feature) { 22 | if (changed_feature !== null) { 23 | res.status(200); 24 | res.send(JSON.stringify({id: changed_feature.id, action:"enable", message: "success"})); 25 | } else { 26 | res.status(400); 27 | res.send(JSON.stringify({error: "400", message: "Invalid input: Feature or user does not exist"})); 28 | return; 29 | } 30 | 31 | }; 32 | 33 | var args = req.body; 34 | ff.enableTo(args.feature_id, args.user_id, handle_result); 35 | }; 36 | 37 | exports.disableTo = function(req, res) { 38 | var handle_result = function(changed_feature) { 39 | if (changed_feature !== null) { 40 | res.status(200); 41 | res.send(JSON.stringify({id: changed_feature.id, action: "disable", message: "success"})); 42 | } else { 43 | res.status(400); 44 | res.send(JSON.stringify({error: "400", message: "Invalid input: Feature or user does not exist"})); 45 | return; 46 | } 47 | }; 48 | 49 | var args = req.body; 50 | ff.disableTo(args.feature_id, args.user_id, handle_result); 51 | }; 52 | 53 | exports.remove = function(req, res) { 54 | var handle_result = function(changed_feature_id) { 55 | res.status(200); 56 | res.send(JSON.stringify({id: changed_feature_id, action: "remove", message: "success"})); 57 | }; 58 | 59 | var args = req.body; 60 | ff.remove(args.feature_id, handle_result); 61 | }; 62 | 63 | exports.check = function(req, res) { 64 | var args = req.body; 65 | var handle_result = function(feature_status) { 66 | var pretty_status = (feature_status) ? "enabled" : "disabled"; 67 | res.status(200); 68 | res.send(JSON.stringify({id: args.feature_id, user: args.user_id, status: pretty_status})); 69 | }; 70 | 71 | ff.check(args.feature_id, args.user_id, handle_result); 72 | }; 73 | -------------------------------------------------------------------------------- /sample_app/frontend.js: -------------------------------------------------------------------------------- 1 | /* This is a sample application that ilustrates how to work with feature flipper 2 | 3 | Hopefully it will get you started. 4 | 5 | ::::DEPENDENCY::: 6 | it requires ExpressJS to run. (http://expressjs.com) 7 | 8 | 9 | ::::DISCLAIMER:::: 10 | sorry for the dirty code. I promise it will get better, but I want to work on more features for the library first. 11 | */ 12 | 13 | var express = require('express'); 14 | var app = express.createServer( 15 | express.logger(), 16 | express.bodyParser() 17 | 18 | ); 19 | var ff_redis = require('../storage/ff_redis')(); 20 | var feature_flipper = require('../feature_flipper'); 21 | 22 | 23 | ff = feature_flipper(ff_redis); 24 | /*creating some sample features*/ 25 | ff.save(ff.create_feature({id: 'custom_welcome', description: 'custom welcome message', enabledTo:['john', 'admin']})); 26 | ff.save(ff.create_feature({id: 'footer', description: 'site footer', enabledTo: 'all'})); 27 | 28 | app.configure(function() { 29 | app.use(express.errorHandler({ dumpExceptions: true, showStack: true })); 30 | app.use(app.router); 31 | 32 | }); 33 | 34 | 35 | app.all('/:user?', function(req, res, next) { 36 | var user = req.params.user; 37 | if (user) { 38 | render_site(req, res, user); 39 | } else { 40 | next(); 41 | } 42 | 43 | }); 44 | 45 | app.get('/', function( req, res) { 46 | res.send('this is a sample application using express and feature_flipper. Please make a request to /<user> where <user> is \'mary\', \'john\' or \'admin\''); 47 | }); 48 | 49 | 50 | render_site = function(req, res, user) { 51 | var body; 52 | render_header(res); /* this feature is 'stable' and not flipped!*/ 53 | ff.check('custom_welcome', user, function(is_enabled) { is_enabled ? render_custom_header(res, user) : render_default_header(res) }); 54 | ff.check('footer', user, function(is_enabled) { is_enabled ? render_footer(res) : render_old_footer(res) }); 55 | }; 56 | 57 | 58 | 59 | 60 | render_header = function(res) { res.write( "

Feature flipper dummy app (nothing huge to see here)

") }; 61 | /* template snippets that are flipped according to the user */ 62 | render_custom_header = function(res, user) { res.write( "

Hello "+user+", this is your custom header

") }; 63 | 64 | render_default_header = function(res) { console.log('called default_header'); res.write( "hello, you are seeing the default header as you user is not flagged to see the customized welcome") }; 65 | 66 | render_footer = function(res) { res.write( "

This is a sample page to show feature_flipper working

"); res.end('-- done --'); }; 67 | 68 | render_old_footer = function(res) { res.write( 'this should not be displayed. something went wrong'); res.end(' :( done '); }; 69 | 70 | 71 | 72 | app.listen(3000); 73 | -------------------------------------------------------------------------------- /README.textile: -------------------------------------------------------------------------------- 1 | !https://travis-ci.org/bigodines/feature-flipper-js.png?branch=master!:https://travis-ci.org/bigodines/feature-flipper-js 2 | 3 | h1. Repo stability: It works but it has been abandoned. 4 | 5 | Use at your own risk. I'm happy to merge/review PRs but I don't intend on developing new features or keep it up to date with the latest features of node. 6 | 7 | h2. FeatureFlipper.js 8 | 9 | __The goal of this project is to provide an easy yet flexible way to test features only with a subset of your users.__ 10 | 11 | Feature Flippers are becoming more and more popular as companies started implementing Lean Startup and Continuous Deployment techniques in their development process. The reason is very simple: It enables you to control which features will be displayed for your users, allowing tests in production without affecting the whole user set. 12 | 13 | We all know that production environments always have some gotchas that we can't fake in tests or can't reproduce in staging environments. How cool would be if we could push something disabled to production and enable it just for us, so we can test before enabling to everyone? Feature Flipper is here to the rescue :-) 14 | 15 | h3. Proposed workflow 16 | 17 | Here is how I use Feature Flipper: 18 | # Develop a feature; 19 | # Add it to feature flipper; 20 | # Enabled it to yourself (it should be ok to use production env as all features are disabled by default); 21 | # Test, test, test; 22 | # Enable to all users; 23 | # After feature is stable, remove the feature that would be rendered instead of the new one. 24 | # Clean up your code and get back to 1) 25 | 26 | h3. Library usage: 27 | 28 | __This library is in it very early stage. So far it supports only basic operation but it is under heavily development and I plan to add many more features in the next couple weeks.__ 29 | 30 | Create a *feature flipper* object passing the storage engine you plan to use and create some features: 31 | 32 |
33 | var feature_flipper = require('../feature_flipper'),
34 |     ff_redis = require('../storage/ff_redis')();    
35 | /* Create your set of features*/
36 | var ff = feature_flipper(ff_redis);
37 |     ff.save(ff.create_feature({id: 'header', 
38 |                        description: 'Site header', 
39 |                        enabledTo : 'all'}));
40 |     ff.save(ff.create_feature({id: 'new_message_bar_layout', 
41 |                        description: 'Testing the new messagebar layout with some users', 
42 |                        enabledTo : ['johnny', 'mark', 'ron', 'some executive']}));
43 |     ff.save(ff.create_feature({id: 'bugfix #35', 
44 |                        description: 'Attempt to fix bug #35 of some product', 
45 |                        enabledTo : ['qa_user_1', 'tati', 'johnny']}));
46 | 
47 | 48 | Then you'll have a way to test wether to render this feature or something else: 49 | 50 |
51 | ff.check('new_message_bar_layout', 'johnny', function(is_enabled) { 
52 |      return is_enabled ? render_feature() : render_something_else();
53 | });
54 | 
55 | ff.check('header', 'johnny', function(is_enabled) {
56 |     return is_enabled ? render_feature() : render_something_else();
57 | });
58 | 
59 | ff.check('bugfix #35', 'some_user_that_will_not_see_this', function (is_enabled) {
60 |     return is_enabled ? render_feature() : render_something_else();
61 | });
62 | 
63 | 64 | Delete a feature: 65 | 66 |
67 | ff.delete('bugfix #35');
68 | 
69 | 70 | h3. Dependencies 71 | 72 | This library currently depends on @mocha@, @should@ and @redis@ modules for nodejs. You can install all of them with @npm@ 73 | 74 | The sample_app/ requires @express@ as well. 75 | 76 | h3. Roadmap: 77 | 78 | Please check 'issues' for more details on what's in the roadmap and bugfixes 79 | 80 | 81 | -------------------------------------------------------------------------------- /test/api_test.js: -------------------------------------------------------------------------------- 1 | var should = require('should'), 2 | flipper = require('../feature_flipper'), 3 | ff_redis = require('../storage/ff_redis')(), 4 | api = require('../admin/routes/api'); 5 | 6 | 7 | describe('API Test - Happy path', function() { 8 | beforeEach(function() { 9 | var ff = flipper(ff_redis); 10 | ff.save(ff.create_feature({id : 'first', description : 'le feature'})); 11 | }), 12 | 13 | afterEach(function(done) { 14 | var redis = require('redis').createClient(); 15 | redis.keys('feature:*', function(err, data) { 16 | redis.mget(data, function(err, features) { 17 | var total = features.length; 18 | for (var i = total; i > 0; i--) { 19 | var feature = JSON.parse(features[i-1]); 20 | redis.del(feature.id, function() { 21 | // console.log(feature.id); 22 | }); 23 | } 24 | done(); 25 | }); 26 | }); 27 | }); 28 | 29 | it('should be possible to create a new feature trought API', function(done) { 30 | var req = { 31 | body : { 32 | id : 'seccond', 33 | description: 'first feature inserted via api' 34 | } 35 | }; 36 | var res = { 37 | send : function(result) { 38 | result.should.equal('{"id":"seccond","description":"first feature inserted via api"}'); 39 | done(); 40 | } 41 | }; 42 | api.create(req, res); 43 | }); 44 | 45 | it('should be possible to enable a feature throught API', function(done) { 46 | var req = { 47 | body : { 48 | feature_id : 'first', 49 | user_id: 'john' 50 | } 51 | }; 52 | var res = { 53 | status : function(code) { 54 | code.should.equal(200); 55 | }, 56 | send : function(result) { 57 | result.should.equal('{"id":"first","action":"enable","message":"success"}'); 58 | done(); 59 | } 60 | }; 61 | api.enableTo(req, res); 62 | }); 63 | 64 | it('should be possible to disable a feature throught API', function(done) { 65 | var req = { 66 | body : { 67 | feature_id : 'first', 68 | user_id: 'john' 69 | } 70 | }; 71 | var res = { 72 | status : function(code) { 73 | code.should.equal(200); 74 | }, 75 | send : function(result) { 76 | result.should.equal('{"id":"first","action":"disable","message":"success"}'); 77 | done(); 78 | } 79 | }; 80 | api.disableTo(req, res); 81 | }); 82 | 83 | it('should be possible to delete a feature using the API', function(done) { 84 | var req = { 85 | body : { 86 | feature_id : 'first', 87 | } 88 | }; 89 | var res = { 90 | status : function(code) { 91 | code.should.equal(200); 92 | }, 93 | send : function(result) { 94 | result.should.equal('{"id":"first","action":"remove","message":"success"}'); 95 | done(); 96 | } 97 | }; 98 | api.remove(req, res); 99 | }); 100 | 101 | it('should be possible to check if a feature is enabled or not for a given user throught the API', function(done) { 102 | var req = { 103 | body : { 104 | feature_id : 'first', 105 | user_id: 'john' 106 | } 107 | }; 108 | var res = { 109 | status : function(code) { 110 | code.should.equal(200); 111 | }, 112 | send : function(result) { 113 | result.should.equal('{"id":"first","user":"john","status":"disabled"}'); 114 | done(); 115 | } 116 | }; 117 | api.check(req, res); 118 | }); 119 | }); 120 | 121 | describe('API Test - Not so happy paths', function() { 122 | beforeEach(function() { 123 | var ff = flipper(ff_redis); 124 | ff.save(ff.create_feature({id : 'first', description : 'le feature'})); 125 | }), 126 | 127 | afterEach(function(done) { 128 | var redis = require('redis').createClient(); 129 | redis.keys('feature:*', function(err, data) { 130 | redis.mget(data, function(err, features) { 131 | var total = features.length; 132 | for (var i = total; i > 0; i--) { 133 | var feature = JSON.parse(features[i-1]); 134 | redis.del(feature.id, function() { 135 | // console.log(feature.id); 136 | }); 137 | } 138 | done(); 139 | }); 140 | }); 141 | }); 142 | 143 | it('should not allow creation of features without required fields', function(done) { 144 | var req = { 145 | body : { 146 | id : 'buggy_with_no_description', 147 | } 148 | }; 149 | var res = { 150 | status : function(code) { 151 | code.should.equal(400); 152 | done(); 153 | }, 154 | send : function(result) { 155 | result.should.equal('{"error":"400","message":"Invalid input"}'); 156 | } 157 | }; 158 | api.create(req, res); 159 | }); 160 | 161 | it('should return error if trying to enable a feature that does not exist', function(done) { 162 | var req = { 163 | body : { 164 | feature_id : 'i_dont_exist', 165 | user_id : 'me neither' 166 | } 167 | }; 168 | var res = { 169 | status : function(code) { 170 | code.should.equal(400); 171 | }, 172 | send : function(result) { 173 | result.should.equal('{"error":"400","message":"Invalid input: Feature or user does not exist"}'); 174 | done(); 175 | } 176 | }; 177 | api.enableTo(req, res); 178 | }); 179 | 180 | it('should return error if trying to enable a feature that does not exist', function(done) { 181 | var req = { 182 | body : { 183 | feature_id : 'i_dont_exist', 184 | user_id : 'me neither' 185 | } 186 | }; 187 | var res = { 188 | status : function(code) { 189 | code.should.equal(400); 190 | }, 191 | send : function(result) { 192 | result.should.equal('{"error":"400","message":"Invalid input: Feature or user does not exist"}'); 193 | done(); 194 | } 195 | }; 196 | api.disableTo(req, res); 197 | }); 198 | 199 | }); 200 | -------------------------------------------------------------------------------- /test/feature_test.js: -------------------------------------------------------------------------------- 1 | var should = require('should'), 2 | flipper = require('../feature_flipper'), 3 | ff_redis = require('../storage/ff_redis')(); 4 | 5 | 6 | describe('Feature', function() { 7 | it('feature_flipper::create_feature() must take option arguments', function() { 8 | var good_feature = flipper().create_feature({id: 'my id', description: 'some nice feature'}); 9 | good_feature.id.should.equal('my id'); 10 | 11 | }); 12 | it('blah', function() { 13 | var bad_feature = flipper; 14 | try { 15 | bad_feature.create_feature(); 16 | } catch (err) { 17 | should.ok('passed'); return; 18 | } 19 | should.fail('Should have thrown an exception'); 20 | }); 21 | 22 | it('feature MUST have ID and DESCRIPTION', function() { 23 | var another_bad_feature = flipper; 24 | try { 25 | another_bad_feature.create_feature({john: 'doe'}); 26 | } catch(err) { 27 | should.ok('nice!'); return; 28 | } 29 | should.fail('Should have thrown an exception'); 30 | 31 | }); 32 | 33 | }); 34 | 35 | describe('How Feature Flipper deals with features', function() { 36 | /* this should work for most of the cases */ 37 | var StorageStub = { 38 | set : function(id, data) { return data; }, 39 | del : function(id) { return 1; }, 40 | get : function(id) { return JSON.stringify({ id : id, description: 'some description' });} 41 | }, 42 | /* more flexible use of the stub */ 43 | StorageStubFactory = function(expected_set_return, expected_del_return, expected_get_return) { 44 | return { 45 | set : function(id, data, cb) { 46 | cb(null,expected_set_return); 47 | }, 48 | del : function(id, cb) { 49 | cb(null,expected_del_return); 50 | }, 51 | get : function(id, cb) { 52 | cb(null,JSON.stringify(expected_get_return)); 53 | } 54 | } 55 | }, 56 | 57 | FeatureStub = function() { 58 | return { 59 | id : 1, 60 | description: 'dummy' 61 | } 62 | }; 63 | 64 | it('should be able to save a feature', function () { 65 | var feature = new FeatureStub(); 66 | feature.id = 'foo'; 67 | var ff = flipper(StorageStub); 68 | var data = ff.save(feature); 69 | should.equal(data.id, feature.id); 70 | }); 71 | 72 | it('should be able to remove a feature', function() { 73 | var feature = new FeatureStub(); 74 | feature.id = 'foo'; 75 | var ff = flipper(StorageStub); 76 | ff.remove(feature, function(id) { 77 | id.should.equal('foo'); 78 | }); 79 | }); 80 | 81 | it('should return a feature object given a feature_id', function() { 82 | var expected = JSON.stringify({id: 'feature:some_id', description : 'some description'}); 83 | var ff = flipper(StorageStub); 84 | var feature = ff.get_feature('some_id'); 85 | expected.should.eql(feature); 86 | }); 87 | 88 | it('should be able to enable a feature to all after it has been created', function(done) { 89 | // test setup 90 | var fake_feature = new FeatureStub(); 91 | var expected_feature = new FeatureStub(); 92 | expected_feature.enabledTo = 'all'; 93 | 94 | var storageMock = StorageStubFactory(expected_feature, null, fake_feature); 95 | 96 | var asyncTest = function(result) { 97 | result.should.eql(expected_feature); 98 | done(); 99 | } 100 | 101 | var ff = flipper(storageMock); 102 | ff.enableTo(1, 'all', asyncTest); 103 | 104 | }); 105 | 106 | it('should be able to enable a feature to an user', function(done) { 107 | // test setup 108 | var fake_feature = new FeatureStub(); 109 | var expected_feature = new FeatureStub(); 110 | expected_feature.enabledTo = ['john']; 111 | 112 | var storageMock = StorageStubFactory(expected_feature, null, fake_feature); 113 | 114 | var asyncTest = function(result) { 115 | result.should.eql(expected_feature); 116 | done(); 117 | } 118 | 119 | var ff = flipper(storageMock); 120 | ff.enableTo(1, 'john', asyncTest); 121 | 122 | }); 123 | 124 | it('should be able to disable a feature for an user', function(done) { 125 | // test setup 126 | var fake_feature = new FeatureStub(); 127 | fake_feature.enabledTo = ['john']; 128 | var expected_feature = new FeatureStub(); 129 | 130 | var storageMock = StorageStubFactory(expected_feature, null, fake_feature); 131 | 132 | var asyncTest = function(result) { 133 | result.should.eql(expected_feature); 134 | done(); 135 | } 136 | 137 | var ff = flipper(storageMock); 138 | ff.disableTo(1, 'john', asyncTest); 139 | }); 140 | 141 | }); 142 | 143 | /*integration tests*/ 144 | describe('Feature Flipper check (integration)', function() { 145 | var ff = flipper(ff_redis); 146 | it('should default to disable', function(done) { 147 | ff.check('i dont exist', function(isEnabled) { 148 | should.not.exist(isEnabled); 149 | done(); 150 | }); 151 | }); 152 | 153 | it('should check if feature is enabled globally when receives one argument', function(done) { 154 | var f = ff.create_feature({id : 'enabled_feature', description : 'enabled to everyone', enabledTo: 'all'}); 155 | ff.save(f); 156 | ff.check('enabled_feature', function(result) { 157 | result.should.equal(true); 158 | ff.remove('enabled_feature'); 159 | done(); 160 | } ); 161 | }); 162 | 163 | it('should be disabled to user id if feature is not enabled', function(done) { 164 | var f = ff.create_feature({id: 'disabled_feature', description: 'disabled to everyone'}); 165 | ff.save(f); 166 | ff.check('disabled_feature', 'user_id', function(result) { 167 | result.should.equal(false); 168 | ff.remove('disabled_feature'); 169 | done(); 170 | }); 171 | }); 172 | 173 | it('should be true to user id if feature is enabled to all', function(done) { 174 | var f = ff.create_feature({id: 'enabled_feature', description: 'enabled to everyone', enabledTo: 'all'}); 175 | ff.save(f); 176 | ff.check('enabled_feature', 'user_id', function(result) { 177 | result.should.equal(true); 178 | ff.remove('enabled_feature'); 179 | done(); 180 | }); 181 | }); 182 | 183 | it('should return true if enabled to this user', function(done) { 184 | var f = ff.create_feature({id: 'enabled_feature', description: 'enabled to mary', enabledTo: ['mary', 'jane']}); 185 | ff.save(f); 186 | ff.check('enabled_feature', 'mary', function(result) { 187 | result.should.equal(true); 188 | ff.remove('enabled_feature'); 189 | done(); 190 | }); 191 | }); 192 | 193 | it('should return false if not enabled to this user', function(done) { 194 | var f = ff.create_feature({id: 'secret_feature', description: 'not enabled to john_doe', enabledTo: ['mary', 'jane']}); 195 | ff.save(f); 196 | ff.check('secret_feature', 'john_doe', function(is_enabled) { 197 | is_enabled.should.equal(false); 198 | ff.remove('secret_feature'); 199 | done(); 200 | }); 201 | }); 202 | }); 203 | 204 | -------------------------------------------------------------------------------- /feature_flipper.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | (function() { 3 | var feature_flipper = function(storage_engine) { 4 | /* internals */ 5 | /* Feature object */ 6 | var Feature = function(options) { 7 | var id, description, creation, expire, enabledTo; // jshint ignore:line 8 | if (typeof options !== 'object') { 9 | throw new Error('please provide an option object'); 10 | } 11 | 12 | for (var i in options) { 13 | this[i] = options[i]; 14 | } 15 | 16 | }; 17 | 18 | 19 | /* Feature Flipper public methods */ 20 | return { 21 | storage : storage_engine, 22 | 23 | /* create feature won't persist the new feature in the storage engine until it is saved */ 24 | create_feature : function(options) { 25 | return new Feature(options); 26 | }, 27 | 28 | get_feature: function(feature_id, data_handler) { 29 | return this.storage.get('feature:'+feature_id, function(err, data) { 30 | if (err || data === null) { 31 | data_handler.call(this, null); 32 | } 33 | var serialized = data; 34 | if (serialized && serialized.length > 0) { 35 | data_handler.call(this, JSON.parse(serialized)); 36 | } 37 | }); 38 | }, 39 | 40 | save : function(feature, data_handler) { 41 | if (typeof data_handler == 'undefined') { 42 | data_handler = function(err, data) { return data; }; 43 | } 44 | 45 | if (feature.id === undefined || feature.description === undefined) { 46 | data_handler('A feature should have at least ID and DESCRIPTION'); 47 | return feature; 48 | } 49 | 50 | this.storage.set('feature:'+feature.id, JSON.stringify(feature), data_handler); 51 | 52 | return feature; 53 | }, 54 | 55 | remove : function(feature, callback) { 56 | var id = (typeof feature === 'string') ? feature : feature.id; 57 | this.storage.del('feature:'+ id); 58 | if (typeof callback !== 'undefined') { 59 | callback(id); 60 | } 61 | }, 62 | 63 | enableTo : function(feature_id, user_id, callback) { 64 | var _self = this; 65 | var enable_feature = function(feature) { 66 | if (user_id === 'all') { 67 | feature.enabledTo = 'all'; 68 | } else { 69 | if (feature.enabledTo instanceof Array) { 70 | feature.enabledTo.push(user_id); 71 | } else { 72 | feature.enabledTo = new Array(user_id); 73 | } 74 | } 75 | var new_feature = _self.save(feature); 76 | callback.call(this, new_feature); 77 | }; 78 | 79 | var deal_with_result = function (is_enabled) { 80 | if (is_enabled === null) { 81 | callback.call(this, null); 82 | return; 83 | } 84 | if (is_enabled === false) { 85 | this.get_feature(feature_id, enable_feature); 86 | } else { 87 | this.get_feature(feature_id, callback); 88 | } 89 | }; 90 | 91 | this.check(feature_id, user_id, deal_with_result); 92 | }, 93 | 94 | //TODO: merge disableTo and enableTo into one single function 95 | disableTo : function(feature_id, user_id, callback) { 96 | var _self = this; 97 | var disable_feature = function(feature) { 98 | if (user_id === 'all') { 99 | delete(feature.enabledTo); 100 | } else { 101 | if (feature.enabledTo instanceof Array) { 102 | var enabled_users = feature.enabledTo; 103 | var current_position = enabled_users.indexOf(user_id); 104 | if (current_position >= 0) { 105 | enabled_users.splice(current_position,1); 106 | if(enabled_users.length === 0) { 107 | delete(feature.enabledTo); 108 | } else { 109 | feature.enabledTo = enabled_users; 110 | } 111 | } 112 | } 113 | } 114 | var new_feature = _self.save(feature); 115 | callback.call(this, new_feature); 116 | }; 117 | 118 | var deal_with_result = function (is_enabled) { 119 | if (is_enabled === true) { 120 | this.get_feature(feature_id, disable_feature); 121 | } else { 122 | this.get_feature(feature_id, callback); 123 | } 124 | }; 125 | 126 | this.check(feature_id, user_id, deal_with_result); 127 | }, 128 | 129 | /*Feature Flipper logic */ 130 | check : function(/* [ all optional params ], after_check_callback(bool is_enabled) */) { 131 | var feature_id, context = this, 132 | argc = arguments.length, 133 | check_cb, after_check, check_against, 134 | _global_check = function(feature) { 135 | if (feature === null) { 136 | after_check.call(context, null); 137 | } 138 | return (feature.enabledTo === 'all'); 139 | }; 140 | 141 | if (argc === 2) { 142 | feature_id = arguments[0]; 143 | after_check = arguments[1]; 144 | check_cb = function(feature) { 145 | if (feature === null) { 146 | after_check.call(context, null); 147 | return; 148 | } 149 | var is_enabled = _global_check(feature); 150 | after_check.call(this, is_enabled); 151 | }; 152 | 153 | } else if (argc === 3) { 154 | feature_id = arguments[0]; 155 | check_against = arguments[1]; 156 | after_check = arguments[2]; 157 | check_cb = function(feature) { 158 | if (feature === null) { 159 | after_check.call(context, null); 160 | return; 161 | } 162 | var is_enabled = _global_check(feature); 163 | if (feature.enabledTo instanceof Array) { 164 | var enabled_users = feature.enabledTo; 165 | is_enabled = enabled_users.reduce(function(prev, curr, idx, arr) { 166 | if (prev === true) { return true; } 167 | return (curr === check_against); 168 | }, false); 169 | } 170 | 171 | after_check.call(context, is_enabled); 172 | }; 173 | } 174 | 175 | context.get_feature(feature_id, check_cb); 176 | } 177 | }; 178 | }; 179 | 180 | module.exports = feature_flipper; 181 | })(); 182 | -------------------------------------------------------------------------------- /admin/public/lib/bootstrap/css/bootstrap-responsive.min.css: -------------------------------------------------------------------------------- 1 | .clearfix{*zoom:1;}.clearfix:before,.clearfix:after{display:table;content:"";} 2 | .clearfix:after{clear:both;} 3 | .hide-text{overflow:hidden;text-indent:100%;white-space:nowrap;} 4 | .input-block-level{display:block;width:100%;min-height:28px;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;-ms-box-sizing:border-box;box-sizing:border-box;} 5 | .hidden{display:none;visibility:hidden;} 6 | .visible-phone{display:none;} 7 | .visible-tablet{display:none;} 8 | .visible-desktop{display:block;} 9 | .hidden-phone{display:block;} 10 | .hidden-tablet{display:block;} 11 | .hidden-desktop{display:none;} 12 | @media (max-width:767px){.visible-phone{display:block;} .hidden-phone{display:none;} .hidden-desktop{display:block;} .visible-desktop{display:none;}}@media (min-width:768px) and (max-width:979px){.visible-tablet{display:block;} .hidden-tablet{display:none;} .hidden-desktop{display:block;} .visible-desktop{display:none;}}@media (max-width:480px){.nav-collapse{-webkit-transform:translate3d(0, 0, 0);} .page-header h1 small{display:block;line-height:18px;} input[type="checkbox"],input[type="radio"]{border:1px solid #ccc;} .form-horizontal .control-group>label{float:none;width:auto;padding-top:0;text-align:left;} .form-horizontal .controls{margin-left:0;} .form-horizontal .control-list{padding-top:0;} .form-horizontal .form-actions{padding-left:10px;padding-right:10px;} .modal{position:absolute;top:10px;left:10px;right:10px;width:auto;margin:0;}.modal.fade.in{top:auto;} .modal-header .close{padding:10px;margin:-10px;} .carousel-caption{position:static;}}@media (max-width:767px){body{padding-left:20px;padding-right:20px;} .navbar-fixed-top{margin-left:-20px;margin-right:-20px;} .container{width:auto;} .row-fluid{width:100%;} .row{margin-left:0;} .row>[class*="span"],.row-fluid>[class*="span"]{float:none;display:block;width:auto;margin:0;} .thumbnails [class*="span"]{width:auto;} input[class*="span"],select[class*="span"],textarea[class*="span"],.uneditable-input{display:block;width:100%;min-height:28px;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;-ms-box-sizing:border-box;box-sizing:border-box;} .input-prepend input[class*="span"],.input-append input[class*="span"]{width:auto;}}@media (min-width:768px) and (max-width:979px){.row{margin-left:-20px;*zoom:1;}.row:before,.row:after{display:table;content:"";} .row:after{clear:both;} [class*="span"]{float:left;margin-left:20px;} .container,.navbar-fixed-top .container,.navbar-fixed-bottom .container{width:724px;} .span12{width:724px;} .span11{width:662px;} .span10{width:600px;} .span9{width:538px;} .span8{width:476px;} .span7{width:414px;} .span6{width:352px;} .span5{width:290px;} .span4{width:228px;} .span3{width:166px;} .span2{width:104px;} .span1{width:42px;} .offset12{margin-left:764px;} .offset11{margin-left:702px;} .offset10{margin-left:640px;} .offset9{margin-left:578px;} .offset8{margin-left:516px;} .offset7{margin-left:454px;} .offset6{margin-left:392px;} .offset5{margin-left:330px;} .offset4{margin-left:268px;} .offset3{margin-left:206px;} .offset2{margin-left:144px;} .offset1{margin-left:82px;} .row-fluid{width:100%;*zoom:1;}.row-fluid:before,.row-fluid:after{display:table;content:"";} .row-fluid:after{clear:both;} .row-fluid>[class*="span"]{float:left;margin-left:2.762430939%;} .row-fluid>[class*="span"]:first-child{margin-left:0;} .row-fluid > .span12{width:99.999999993%;} .row-fluid > .span11{width:91.436464082%;} .row-fluid > .span10{width:82.87292817100001%;} .row-fluid > .span9{width:74.30939226%;} .row-fluid > .span8{width:65.74585634900001%;} .row-fluid > .span7{width:57.182320438000005%;} .row-fluid > .span6{width:48.618784527%;} .row-fluid > .span5{width:40.055248616%;} .row-fluid > .span4{width:31.491712705%;} .row-fluid > .span3{width:22.928176794%;} .row-fluid > .span2{width:14.364640883%;} .row-fluid > .span1{width:5.801104972%;} input,textarea,.uneditable-input{margin-left:0;} input.span12, textarea.span12, .uneditable-input.span12{width:714px;} input.span11, textarea.span11, .uneditable-input.span11{width:652px;} input.span10, textarea.span10, .uneditable-input.span10{width:590px;} input.span9, textarea.span9, .uneditable-input.span9{width:528px;} input.span8, textarea.span8, .uneditable-input.span8{width:466px;} input.span7, textarea.span7, .uneditable-input.span7{width:404px;} input.span6, textarea.span6, .uneditable-input.span6{width:342px;} input.span5, textarea.span5, .uneditable-input.span5{width:280px;} input.span4, textarea.span4, .uneditable-input.span4{width:218px;} input.span3, textarea.span3, .uneditable-input.span3{width:156px;} input.span2, textarea.span2, .uneditable-input.span2{width:94px;} input.span1, textarea.span1, .uneditable-input.span1{width:32px;}}@media (max-width:979px){body{padding-top:0;} .navbar-fixed-top{position:static;margin-bottom:18px;} .navbar-fixed-top .navbar-inner{padding:5px;} .navbar .container{width:auto;padding:0;} .navbar .brand{padding-left:10px;padding-right:10px;margin:0 0 0 -5px;} .navbar .nav-collapse{clear:left;} .navbar .nav{float:none;margin:0 0 9px;} .navbar .nav>li{float:none;} .navbar .nav>li>a{margin-bottom:2px;} .navbar .nav>.divider-vertical{display:none;} .navbar .nav .nav-header{color:#999999;text-shadow:none;} .navbar .nav>li>a,.navbar .dropdown-menu a{padding:6px 15px;font-weight:bold;color:#999999;-webkit-border-radius:3px;-moz-border-radius:3px;border-radius:3px;} .navbar .dropdown-menu li+li a{margin-bottom:2px;} .navbar .nav>li>a:hover,.navbar .dropdown-menu a:hover{background-color:#222222;} .navbar .dropdown-menu{position:static;top:auto;left:auto;float:none;display:block;max-width:none;margin:0 15px;padding:0;background-color:transparent;border:none;-webkit-border-radius:0;-moz-border-radius:0;border-radius:0;-webkit-box-shadow:none;-moz-box-shadow:none;box-shadow:none;} .navbar .dropdown-menu:before,.navbar .dropdown-menu:after{display:none;} .navbar .dropdown-menu .divider{display:none;} .navbar-form,.navbar-search{float:none;padding:9px 15px;margin:9px 0;border-top:1px solid #222222;border-bottom:1px solid #222222;-webkit-box-shadow:inset 0 1px 0 rgba(255, 255, 255, 0.1),0 1px 0 rgba(255, 255, 255, 0.1);-moz-box-shadow:inset 0 1px 0 rgba(255, 255, 255, 0.1),0 1px 0 rgba(255, 255, 255, 0.1);box-shadow:inset 0 1px 0 rgba(255, 255, 255, 0.1),0 1px 0 rgba(255, 255, 255, 0.1);} .navbar .nav.pull-right{float:none;margin-left:0;} .navbar-static .navbar-inner{padding-left:10px;padding-right:10px;} .btn-navbar{display:block;} .nav-collapse{overflow:hidden;height:0;}}@media (min-width:980px){.nav-collapse.collapse{height:auto !important;overflow:visible !important;}}@media (min-width:1200px){.row{margin-left:-30px;*zoom:1;}.row:before,.row:after{display:table;content:"";} .row:after{clear:both;} [class*="span"]{float:left;margin-left:30px;} .container,.navbar-fixed-top .container,.navbar-fixed-bottom .container{width:1170px;} .span12{width:1170px;} .span11{width:1070px;} .span10{width:970px;} .span9{width:870px;} .span8{width:770px;} .span7{width:670px;} .span6{width:570px;} .span5{width:470px;} .span4{width:370px;} .span3{width:270px;} .span2{width:170px;} .span1{width:70px;} .offset12{margin-left:1230px;} .offset11{margin-left:1130px;} .offset10{margin-left:1030px;} .offset9{margin-left:930px;} .offset8{margin-left:830px;} .offset7{margin-left:730px;} .offset6{margin-left:630px;} .offset5{margin-left:530px;} .offset4{margin-left:430px;} .offset3{margin-left:330px;} .offset2{margin-left:230px;} .offset1{margin-left:130px;} .row-fluid{width:100%;*zoom:1;}.row-fluid:before,.row-fluid:after{display:table;content:"";} .row-fluid:after{clear:both;} .row-fluid>[class*="span"]{float:left;margin-left:2.564102564%;} .row-fluid>[class*="span"]:first-child{margin-left:0;} .row-fluid > .span12{width:100%;} .row-fluid > .span11{width:91.45299145300001%;} .row-fluid > .span10{width:82.905982906%;} .row-fluid > .span9{width:74.358974359%;} .row-fluid > .span8{width:65.81196581200001%;} .row-fluid > .span7{width:57.264957265%;} .row-fluid > .span6{width:48.717948718%;} .row-fluid > .span5{width:40.170940171000005%;} .row-fluid > .span4{width:31.623931624%;} .row-fluid > .span3{width:23.076923077%;} .row-fluid > .span2{width:14.529914530000001%;} .row-fluid > .span1{width:5.982905983%;} input,textarea,.uneditable-input{margin-left:0;} input.span12, textarea.span12, .uneditable-input.span12{width:1160px;} input.span11, textarea.span11, .uneditable-input.span11{width:1060px;} input.span10, textarea.span10, .uneditable-input.span10{width:960px;} input.span9, textarea.span9, .uneditable-input.span9{width:860px;} input.span8, textarea.span8, .uneditable-input.span8{width:760px;} input.span7, textarea.span7, .uneditable-input.span7{width:660px;} input.span6, textarea.span6, .uneditable-input.span6{width:560px;} input.span5, textarea.span5, .uneditable-input.span5{width:460px;} input.span4, textarea.span4, .uneditable-input.span4{width:360px;} input.span3, textarea.span3, .uneditable-input.span3{width:260px;} input.span2, textarea.span2, .uneditable-input.span2{width:160px;} input.span1, textarea.span1, .uneditable-input.span1{width:60px;} .thumbnails{margin-left:-30px;} .thumbnails>li{margin-left:30px;}} 13 | -------------------------------------------------------------------------------- /admin/public/lib/bootstrap/css/bootstrap-responsive.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap Responsive v2.0.2 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 | .clearfix { 11 | *zoom: 1; 12 | } 13 | .clearfix:before, 14 | .clearfix:after { 15 | display: table; 16 | content: ""; 17 | } 18 | .clearfix:after { 19 | clear: both; 20 | } 21 | .hide-text { 22 | overflow: hidden; 23 | text-indent: 100%; 24 | white-space: nowrap; 25 | } 26 | .input-block-level { 27 | display: block; 28 | width: 100%; 29 | min-height: 28px; 30 | /* Make inputs at least the height of their button counterpart */ 31 | 32 | /* Makes inputs behave like true block-level elements */ 33 | 34 | -webkit-box-sizing: border-box; 35 | -moz-box-sizing: border-box; 36 | -ms-box-sizing: border-box; 37 | box-sizing: border-box; 38 | } 39 | .hidden { 40 | display: none; 41 | visibility: hidden; 42 | } 43 | .visible-phone { 44 | display: none; 45 | } 46 | .visible-tablet { 47 | display: none; 48 | } 49 | .visible-desktop { 50 | display: block; 51 | } 52 | .hidden-phone { 53 | display: block; 54 | } 55 | .hidden-tablet { 56 | display: block; 57 | } 58 | .hidden-desktop { 59 | display: none; 60 | } 61 | @media (max-width: 767px) { 62 | .visible-phone { 63 | display: block; 64 | } 65 | .hidden-phone { 66 | display: none; 67 | } 68 | .hidden-desktop { 69 | display: block; 70 | } 71 | .visible-desktop { 72 | display: none; 73 | } 74 | } 75 | @media (min-width: 768px) and (max-width: 979px) { 76 | .visible-tablet { 77 | display: block; 78 | } 79 | .hidden-tablet { 80 | display: none; 81 | } 82 | .hidden-desktop { 83 | display: block; 84 | } 85 | .visible-desktop { 86 | display: none; 87 | } 88 | } 89 | @media (max-width: 480px) { 90 | .nav-collapse { 91 | -webkit-transform: translate3d(0, 0, 0); 92 | } 93 | .page-header h1 small { 94 | display: block; 95 | line-height: 18px; 96 | } 97 | input[type="checkbox"], 98 | input[type="radio"] { 99 | border: 1px solid #ccc; 100 | } 101 | .form-horizontal .control-group > label { 102 | float: none; 103 | width: auto; 104 | padding-top: 0; 105 | text-align: left; 106 | } 107 | .form-horizontal .controls { 108 | margin-left: 0; 109 | } 110 | .form-horizontal .control-list { 111 | padding-top: 0; 112 | } 113 | .form-horizontal .form-actions { 114 | padding-left: 10px; 115 | padding-right: 10px; 116 | } 117 | .modal { 118 | position: absolute; 119 | top: 10px; 120 | left: 10px; 121 | right: 10px; 122 | width: auto; 123 | margin: 0; 124 | } 125 | .modal.fade.in { 126 | top: auto; 127 | } 128 | .modal-header .close { 129 | padding: 10px; 130 | margin: -10px; 131 | } 132 | .carousel-caption { 133 | position: static; 134 | } 135 | } 136 | @media (max-width: 767px) { 137 | body { 138 | padding-left: 20px; 139 | padding-right: 20px; 140 | } 141 | .navbar-fixed-top { 142 | margin-left: -20px; 143 | margin-right: -20px; 144 | } 145 | .container { 146 | width: auto; 147 | } 148 | .row-fluid { 149 | width: 100%; 150 | } 151 | .row { 152 | margin-left: 0; 153 | } 154 | .row > [class*="span"], 155 | .row-fluid > [class*="span"] { 156 | float: none; 157 | display: block; 158 | width: auto; 159 | margin: 0; 160 | } 161 | .thumbnails [class*="span"] { 162 | width: auto; 163 | } 164 | input[class*="span"], 165 | select[class*="span"], 166 | textarea[class*="span"], 167 | .uneditable-input { 168 | display: block; 169 | width: 100%; 170 | min-height: 28px; 171 | /* Make inputs at least the height of their button counterpart */ 172 | 173 | /* Makes inputs behave like true block-level elements */ 174 | 175 | -webkit-box-sizing: border-box; 176 | -moz-box-sizing: border-box; 177 | -ms-box-sizing: border-box; 178 | box-sizing: border-box; 179 | } 180 | .input-prepend input[class*="span"], 181 | .input-append input[class*="span"] { 182 | width: auto; 183 | } 184 | } 185 | @media (min-width: 768px) and (max-width: 979px) { 186 | .row { 187 | margin-left: -20px; 188 | *zoom: 1; 189 | } 190 | .row:before, 191 | .row:after { 192 | display: table; 193 | content: ""; 194 | } 195 | .row:after { 196 | clear: both; 197 | } 198 | [class*="span"] { 199 | float: left; 200 | margin-left: 20px; 201 | } 202 | .container, 203 | .navbar-fixed-top .container, 204 | .navbar-fixed-bottom .container { 205 | width: 724px; 206 | } 207 | .span12 { 208 | width: 724px; 209 | } 210 | .span11 { 211 | width: 662px; 212 | } 213 | .span10 { 214 | width: 600px; 215 | } 216 | .span9 { 217 | width: 538px; 218 | } 219 | .span8 { 220 | width: 476px; 221 | } 222 | .span7 { 223 | width: 414px; 224 | } 225 | .span6 { 226 | width: 352px; 227 | } 228 | .span5 { 229 | width: 290px; 230 | } 231 | .span4 { 232 | width: 228px; 233 | } 234 | .span3 { 235 | width: 166px; 236 | } 237 | .span2 { 238 | width: 104px; 239 | } 240 | .span1 { 241 | width: 42px; 242 | } 243 | .offset12 { 244 | margin-left: 764px; 245 | } 246 | .offset11 { 247 | margin-left: 702px; 248 | } 249 | .offset10 { 250 | margin-left: 640px; 251 | } 252 | .offset9 { 253 | margin-left: 578px; 254 | } 255 | .offset8 { 256 | margin-left: 516px; 257 | } 258 | .offset7 { 259 | margin-left: 454px; 260 | } 261 | .offset6 { 262 | margin-left: 392px; 263 | } 264 | .offset5 { 265 | margin-left: 330px; 266 | } 267 | .offset4 { 268 | margin-left: 268px; 269 | } 270 | .offset3 { 271 | margin-left: 206px; 272 | } 273 | .offset2 { 274 | margin-left: 144px; 275 | } 276 | .offset1 { 277 | margin-left: 82px; 278 | } 279 | .row-fluid { 280 | width: 100%; 281 | *zoom: 1; 282 | } 283 | .row-fluid:before, 284 | .row-fluid:after { 285 | display: table; 286 | content: ""; 287 | } 288 | .row-fluid:after { 289 | clear: both; 290 | } 291 | .row-fluid > [class*="span"] { 292 | float: left; 293 | margin-left: 2.762430939%; 294 | } 295 | .row-fluid > [class*="span"]:first-child { 296 | margin-left: 0; 297 | } 298 | .row-fluid > .span12 { 299 | width: 99.999999993%; 300 | } 301 | .row-fluid > .span11 { 302 | width: 91.436464082%; 303 | } 304 | .row-fluid > .span10 { 305 | width: 82.87292817100001%; 306 | } 307 | .row-fluid > .span9 { 308 | width: 74.30939226%; 309 | } 310 | .row-fluid > .span8 { 311 | width: 65.74585634900001%; 312 | } 313 | .row-fluid > .span7 { 314 | width: 57.182320438000005%; 315 | } 316 | .row-fluid > .span6 { 317 | width: 48.618784527%; 318 | } 319 | .row-fluid > .span5 { 320 | width: 40.055248616%; 321 | } 322 | .row-fluid > .span4 { 323 | width: 31.491712705%; 324 | } 325 | .row-fluid > .span3 { 326 | width: 22.928176794%; 327 | } 328 | .row-fluid > .span2 { 329 | width: 14.364640883%; 330 | } 331 | .row-fluid > .span1 { 332 | width: 5.801104972%; 333 | } 334 | input, 335 | textarea, 336 | .uneditable-input { 337 | margin-left: 0; 338 | } 339 | input.span12, textarea.span12, .uneditable-input.span12 { 340 | width: 714px; 341 | } 342 | input.span11, textarea.span11, .uneditable-input.span11 { 343 | width: 652px; 344 | } 345 | input.span10, textarea.span10, .uneditable-input.span10 { 346 | width: 590px; 347 | } 348 | input.span9, textarea.span9, .uneditable-input.span9 { 349 | width: 528px; 350 | } 351 | input.span8, textarea.span8, .uneditable-input.span8 { 352 | width: 466px; 353 | } 354 | input.span7, textarea.span7, .uneditable-input.span7 { 355 | width: 404px; 356 | } 357 | input.span6, textarea.span6, .uneditable-input.span6 { 358 | width: 342px; 359 | } 360 | input.span5, textarea.span5, .uneditable-input.span5 { 361 | width: 280px; 362 | } 363 | input.span4, textarea.span4, .uneditable-input.span4 { 364 | width: 218px; 365 | } 366 | input.span3, textarea.span3, .uneditable-input.span3 { 367 | width: 156px; 368 | } 369 | input.span2, textarea.span2, .uneditable-input.span2 { 370 | width: 94px; 371 | } 372 | input.span1, textarea.span1, .uneditable-input.span1 { 373 | width: 32px; 374 | } 375 | } 376 | @media (max-width: 979px) { 377 | body { 378 | padding-top: 0; 379 | } 380 | .navbar-fixed-top { 381 | position: static; 382 | margin-bottom: 18px; 383 | } 384 | .navbar-fixed-top .navbar-inner { 385 | padding: 5px; 386 | } 387 | .navbar .container { 388 | width: auto; 389 | padding: 0; 390 | } 391 | .navbar .brand { 392 | padding-left: 10px; 393 | padding-right: 10px; 394 | margin: 0 0 0 -5px; 395 | } 396 | .navbar .nav-collapse { 397 | clear: left; 398 | } 399 | .navbar .nav { 400 | float: none; 401 | margin: 0 0 9px; 402 | } 403 | .navbar .nav > li { 404 | float: none; 405 | } 406 | .navbar .nav > li > a { 407 | margin-bottom: 2px; 408 | } 409 | .navbar .nav > .divider-vertical { 410 | display: none; 411 | } 412 | .navbar .nav .nav-header { 413 | color: #999999; 414 | text-shadow: none; 415 | } 416 | .navbar .nav > li > a, 417 | .navbar .dropdown-menu a { 418 | padding: 6px 15px; 419 | font-weight: bold; 420 | color: #999999; 421 | -webkit-border-radius: 3px; 422 | -moz-border-radius: 3px; 423 | border-radius: 3px; 424 | } 425 | .navbar .dropdown-menu li + li a { 426 | margin-bottom: 2px; 427 | } 428 | .navbar .nav > li > a:hover, 429 | .navbar .dropdown-menu a:hover { 430 | background-color: #222222; 431 | } 432 | .navbar .dropdown-menu { 433 | position: static; 434 | top: auto; 435 | left: auto; 436 | float: none; 437 | display: block; 438 | max-width: none; 439 | margin: 0 15px; 440 | padding: 0; 441 | background-color: transparent; 442 | border: none; 443 | -webkit-border-radius: 0; 444 | -moz-border-radius: 0; 445 | border-radius: 0; 446 | -webkit-box-shadow: none; 447 | -moz-box-shadow: none; 448 | box-shadow: none; 449 | } 450 | .navbar .dropdown-menu:before, 451 | .navbar .dropdown-menu:after { 452 | display: none; 453 | } 454 | .navbar .dropdown-menu .divider { 455 | display: none; 456 | } 457 | .navbar-form, 458 | .navbar-search { 459 | float: none; 460 | padding: 9px 15px; 461 | margin: 9px 0; 462 | border-top: 1px solid #222222; 463 | border-bottom: 1px solid #222222; 464 | -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1), 0 1px 0 rgba(255, 255, 255, 0.1); 465 | -moz-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1), 0 1px 0 rgba(255, 255, 255, 0.1); 466 | box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1), 0 1px 0 rgba(255, 255, 255, 0.1); 467 | } 468 | .navbar .nav.pull-right { 469 | float: none; 470 | margin-left: 0; 471 | } 472 | .navbar-static .navbar-inner { 473 | padding-left: 10px; 474 | padding-right: 10px; 475 | } 476 | .btn-navbar { 477 | display: block; 478 | } 479 | .nav-collapse { 480 | overflow: hidden; 481 | height: 0; 482 | } 483 | } 484 | @media (min-width: 980px) { 485 | .nav-collapse.collapse { 486 | height: auto !important; 487 | overflow: visible !important; 488 | } 489 | } 490 | @media (min-width: 1200px) { 491 | .row { 492 | margin-left: -30px; 493 | *zoom: 1; 494 | } 495 | .row:before, 496 | .row:after { 497 | display: table; 498 | content: ""; 499 | } 500 | .row:after { 501 | clear: both; 502 | } 503 | [class*="span"] { 504 | float: left; 505 | margin-left: 30px; 506 | } 507 | .container, 508 | .navbar-fixed-top .container, 509 | .navbar-fixed-bottom .container { 510 | width: 1170px; 511 | } 512 | .span12 { 513 | width: 1170px; 514 | } 515 | .span11 { 516 | width: 1070px; 517 | } 518 | .span10 { 519 | width: 970px; 520 | } 521 | .span9 { 522 | width: 870px; 523 | } 524 | .span8 { 525 | width: 770px; 526 | } 527 | .span7 { 528 | width: 670px; 529 | } 530 | .span6 { 531 | width: 570px; 532 | } 533 | .span5 { 534 | width: 470px; 535 | } 536 | .span4 { 537 | width: 370px; 538 | } 539 | .span3 { 540 | width: 270px; 541 | } 542 | .span2 { 543 | width: 170px; 544 | } 545 | .span1 { 546 | width: 70px; 547 | } 548 | .offset12 { 549 | margin-left: 1230px; 550 | } 551 | .offset11 { 552 | margin-left: 1130px; 553 | } 554 | .offset10 { 555 | margin-left: 1030px; 556 | } 557 | .offset9 { 558 | margin-left: 930px; 559 | } 560 | .offset8 { 561 | margin-left: 830px; 562 | } 563 | .offset7 { 564 | margin-left: 730px; 565 | } 566 | .offset6 { 567 | margin-left: 630px; 568 | } 569 | .offset5 { 570 | margin-left: 530px; 571 | } 572 | .offset4 { 573 | margin-left: 430px; 574 | } 575 | .offset3 { 576 | margin-left: 330px; 577 | } 578 | .offset2 { 579 | margin-left: 230px; 580 | } 581 | .offset1 { 582 | margin-left: 130px; 583 | } 584 | .row-fluid { 585 | width: 100%; 586 | *zoom: 1; 587 | } 588 | .row-fluid:before, 589 | .row-fluid:after { 590 | display: table; 591 | content: ""; 592 | } 593 | .row-fluid:after { 594 | clear: both; 595 | } 596 | .row-fluid > [class*="span"] { 597 | float: left; 598 | margin-left: 2.564102564%; 599 | } 600 | .row-fluid > [class*="span"]:first-child { 601 | margin-left: 0; 602 | } 603 | .row-fluid > .span12 { 604 | width: 100%; 605 | } 606 | .row-fluid > .span11 { 607 | width: 91.45299145300001%; 608 | } 609 | .row-fluid > .span10 { 610 | width: 82.905982906%; 611 | } 612 | .row-fluid > .span9 { 613 | width: 74.358974359%; 614 | } 615 | .row-fluid > .span8 { 616 | width: 65.81196581200001%; 617 | } 618 | .row-fluid > .span7 { 619 | width: 57.264957265%; 620 | } 621 | .row-fluid > .span6 { 622 | width: 48.717948718%; 623 | } 624 | .row-fluid > .span5 { 625 | width: 40.170940171000005%; 626 | } 627 | .row-fluid > .span4 { 628 | width: 31.623931624%; 629 | } 630 | .row-fluid > .span3 { 631 | width: 23.076923077%; 632 | } 633 | .row-fluid > .span2 { 634 | width: 14.529914530000001%; 635 | } 636 | .row-fluid > .span1 { 637 | width: 5.982905983%; 638 | } 639 | input, 640 | textarea, 641 | .uneditable-input { 642 | margin-left: 0; 643 | } 644 | input.span12, textarea.span12, .uneditable-input.span12 { 645 | width: 1160px; 646 | } 647 | input.span11, textarea.span11, .uneditable-input.span11 { 648 | width: 1060px; 649 | } 650 | input.span10, textarea.span10, .uneditable-input.span10 { 651 | width: 960px; 652 | } 653 | input.span9, textarea.span9, .uneditable-input.span9 { 654 | width: 860px; 655 | } 656 | input.span8, textarea.span8, .uneditable-input.span8 { 657 | width: 760px; 658 | } 659 | input.span7, textarea.span7, .uneditable-input.span7 { 660 | width: 660px; 661 | } 662 | input.span6, textarea.span6, .uneditable-input.span6 { 663 | width: 560px; 664 | } 665 | input.span5, textarea.span5, .uneditable-input.span5 { 666 | width: 460px; 667 | } 668 | input.span4, textarea.span4, .uneditable-input.span4 { 669 | width: 360px; 670 | } 671 | input.span3, textarea.span3, .uneditable-input.span3 { 672 | width: 260px; 673 | } 674 | input.span2, textarea.span2, .uneditable-input.span2 { 675 | width: 160px; 676 | } 677 | input.span1, textarea.span1, .uneditable-input.span1 { 678 | width: 60px; 679 | } 680 | .thumbnails { 681 | margin-left: -30px; 682 | } 683 | .thumbnails > li { 684 | margin-left: 30px; 685 | } 686 | } 687 | -------------------------------------------------------------------------------- /admin/public/lib/bootstrap/js/bootstrap.min.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Bootstrap.js by @fat & @mdo 3 | * Copyright 2012 Twitter, Inc. 4 | * http://www.apache.org/licenses/LICENSE-2.0.txt 5 | */ 6 | !function(a){a(function(){"use strict",a.support.transition=function(){var b=document.body||document.documentElement,c=b.style,d=c.transition!==undefined||c.WebkitTransition!==undefined||c.MozTransition!==undefined||c.MsTransition!==undefined||c.OTransition!==undefined;return d&&{end:function(){var b="TransitionEnd";return a.browser.webkit?b="webkitTransitionEnd":a.browser.mozilla?b="transitionend":a.browser.opera&&(b="oTransitionEnd"),b}()}}()})}(window.jQuery),!function(a){"use strict";var b='[data-dismiss="alert"]',c=function(c){a(c).on("click",b,this.close)};c.prototype={constructor:c,close:function(b){function f(){e.trigger("closed").remove()}var c=a(this),d=c.attr("data-target"),e;d||(d=c.attr("href"),d=d&&d.replace(/.*(?=#[^\s]*$)/,"")),e=a(d),e.trigger("close"),b&&b.preventDefault(),e.length||(e=c.hasClass("alert")?c:c.parent()),e.trigger("close").removeClass("in"),a.support.transition&&e.hasClass("fade")?e.on(a.support.transition.end,f):f()}},a.fn.alert=function(b){return this.each(function(){var d=a(this),e=d.data("alert");e||d.data("alert",e=new c(this)),typeof b=="string"&&e[b].call(d)})},a.fn.alert.Constructor=c,a(function(){a("body").on("click.alert.data-api",b,c.prototype.close)})}(window.jQuery),!function(a){"use strict";var b=function(b,c){this.$element=a(b),this.options=a.extend({},a.fn.button.defaults,c)};b.prototype={constructor:b,setState:function(a){var b="disabled",c=this.$element,d=c.data(),e=c.is("input")?"val":"html";a+="Text",d.resetText||c.data("resetText",c[e]()),c[e](d[a]||this.options[a]),setTimeout(function(){a=="loadingText"?c.addClass(b).attr(b,b):c.removeClass(b).removeAttr(b)},0)},toggle:function(){var a=this.$element.parent('[data-toggle="buttons-radio"]');a&&a.find(".active").removeClass("active"),this.$element.toggleClass("active")}},a.fn.button=function(c){return this.each(function(){var d=a(this),e=d.data("button"),f=typeof c=="object"&&c;e||d.data("button",e=new b(this,f)),c=="toggle"?e.toggle():c&&e.setState(c)})},a.fn.button.defaults={loadingText:"loading..."},a.fn.button.Constructor=b,a(function(){a("body").on("click.button.data-api","[data-toggle^=button]",function(b){var c=a(b.target);c.hasClass("btn")||(c=c.closest(".btn")),c.button("toggle")})})}(window.jQuery),!function(a){"use strict";var b=function(b,c){this.$element=a(b),this.options=a.extend({},a.fn.carousel.defaults,c),this.options.slide&&this.slide(this.options.slide),this.options.pause=="hover"&&this.$element.on("mouseenter",a.proxy(this.pause,this)).on("mouseleave",a.proxy(this.cycle,this))};b.prototype={cycle:function(){return this.interval=setInterval(a.proxy(this.next,this),this.options.interval),this},to:function(b){var c=this.$element.find(".active"),d=c.parent().children(),e=d.index(c),f=this;if(b>d.length-1||b<0)return;return this.sliding?this.$element.one("slid",function(){f.to(b)}):e==b?this.pause().cycle():this.slide(b>e?"next":"prev",a(d[b]))},pause:function(){return clearInterval(this.interval),this.interval=null,this},next:function(){if(this.sliding)return;return this.slide("next")},prev:function(){if(this.sliding)return;return this.slide("prev")},slide:function(b,c){var d=this.$element.find(".active"),e=c||d[b](),f=this.interval,g=b=="next"?"left":"right",h=b=="next"?"first":"last",i=this;this.sliding=!0,f&&this.pause(),e=e.length?e:this.$element.find(".item")[h]();if(e.hasClass("active"))return;return!a.support.transition&&this.$element.hasClass("slide")?(this.$element.trigger("slide"),d.removeClass("active"),e.addClass("active"),this.sliding=!1,this.$element.trigger("slid")):(e.addClass(b),e[0].offsetWidth,d.addClass(g),e.addClass(g),this.$element.trigger("slide"),this.$element.one(a.support.transition.end,function(){e.removeClass([b,g].join(" ")).addClass("active"),d.removeClass(["active",g].join(" ")),i.sliding=!1,setTimeout(function(){i.$element.trigger("slid")},0)})),f&&this.cycle(),this}},a.fn.carousel=function(c){return this.each(function(){var d=a(this),e=d.data("carousel"),f=typeof c=="object"&&c;e||d.data("carousel",e=new b(this,f)),typeof c=="number"?e.to(c):typeof c=="string"||(c=f.slide)?e[c]():e.cycle()})},a.fn.carousel.defaults={interval:5e3,pause:"hover"},a.fn.carousel.Constructor=b,a(function(){a("body").on("click.carousel.data-api","[data-slide]",function(b){var c=a(this),d,e=a(c.attr("data-target")||(d=c.attr("href"))&&d.replace(/.*(?=#[^\s]+$)/,"")),f=!e.data("modal")&&a.extend({},e.data(),c.data());e.carousel(f),b.preventDefault()})})}(window.jQuery),!function(a){"use strict";var b=function(b,c){this.$element=a(b),this.options=a.extend({},a.fn.collapse.defaults,c),this.options.parent&&(this.$parent=a(this.options.parent)),this.options.toggle&&this.toggle()};b.prototype={constructor:b,dimension:function(){var a=this.$element.hasClass("width");return a?"width":"height"},show:function(){var b=this.dimension(),c=a.camelCase(["scroll",b].join("-")),d=this.$parent&&this.$parent.find(".in"),e;d&&d.length&&(e=d.data("collapse"),d.collapse("hide"),e||d.data("collapse",null)),this.$element[b](0),this.transition("addClass","show","shown"),this.$element[b](this.$element[0][c])},hide:function(){var a=this.dimension();this.reset(this.$element[a]()),this.transition("removeClass","hide","hidden"),this.$element[a](0)},reset:function(a){var b=this.dimension();return this.$element.removeClass("collapse")[b](a||"auto")[0].offsetWidth,this.$element[a?"addClass":"removeClass"]("collapse"),this},transition:function(b,c,d){var e=this,f=function(){c=="show"&&e.reset(),e.$element.trigger(d)};this.$element.trigger(c)[b]("in"),a.support.transition&&this.$element.hasClass("collapse")?this.$element.one(a.support.transition.end,f):f()},toggle:function(){this[this.$element.hasClass("in")?"hide":"show"]()}},a.fn.collapse=function(c){return this.each(function(){var d=a(this),e=d.data("collapse"),f=typeof c=="object"&&c;e||d.data("collapse",e=new b(this,f)),typeof c=="string"&&e[c]()})},a.fn.collapse.defaults={toggle:!0},a.fn.collapse.Constructor=b,a(function(){a("body").on("click.collapse.data-api","[data-toggle=collapse]",function(b){var c=a(this),d,e=c.attr("data-target")||b.preventDefault()||(d=c.attr("href"))&&d.replace(/.*(?=#[^\s]+$)/,""),f=a(e).data("collapse")?"toggle":c.data();a(e).collapse(f)})})}(window.jQuery),!function(a){function d(){a(b).parent().removeClass("open")}"use strict";var b='[data-toggle="dropdown"]',c=function(b){var c=a(b).on("click.dropdown.data-api",this.toggle);a("html").on("click.dropdown.data-api",function(){c.parent().removeClass("open")})};c.prototype={constructor:c,toggle:function(b){var c=a(this),e=c.attr("data-target"),f,g;return e||(e=c.attr("href"),e=e&&e.replace(/.*(?=#[^\s]*$)/,"")),f=a(e),f.length||(f=c.parent()),g=f.hasClass("open"),d(),!g&&f.toggleClass("open"),!1}},a.fn.dropdown=function(b){return this.each(function(){var d=a(this),e=d.data("dropdown");e||d.data("dropdown",e=new c(this)),typeof b=="string"&&e[b].call(d)})},a.fn.dropdown.Constructor=c,a(function(){a("html").on("click.dropdown.data-api",d),a("body").on("click.dropdown.data-api",b,c.prototype.toggle)})}(window.jQuery),!function(a){function c(){var b=this,c=setTimeout(function(){b.$element.off(a.support.transition.end),d.call(b)},500);this.$element.one(a.support.transition.end,function(){clearTimeout(c),d.call(b)})}function d(a){this.$element.hide().trigger("hidden"),e.call(this)}function e(b){var c=this,d=this.$element.hasClass("fade")?"fade":"";if(this.isShown&&this.options.backdrop){var e=a.support.transition&&d;this.$backdrop=a('